From 0afa06ced6c51f99f41e3896453192b063bf87ec Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:03 -0400 Subject: [PATCH 01/18] refactor: route remaining survey callers through the testable shim Adds testable.Ask for multi-question forms and replaces ten direct survey.AskOne / survey.Ask callers that bypassed the shim. --- pkg/alias/alias.go | 3 ++- pkg/granted/credentials.go | 2 +- pkg/granted/registry/gitregistry/config_yaml.go | 3 ++- pkg/granted/registry/setup.go | 3 ++- pkg/granted/settings/requesturl/set.go | 3 ++- pkg/granted/settings/set.go | 9 +++++---- pkg/granted/uninstall.go | 3 ++- pkg/testable/testable.go | 14 ++++++++++++++ 8 files changed, 30 insertions(+), 10 deletions(-) diff --git a/pkg/alias/alias.go b/pkg/alias/alias.go index ce995816..719f97be 100644 --- a/pkg/alias/alias.go +++ b/pkg/alias/alias.go @@ -18,6 +18,7 @@ import ( "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/shells" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/fatih/color" ) @@ -203,7 +204,7 @@ func SetupShellWizard(autoConfigure bool) error { Default: true, } var confirm bool - err = survey.AskOne(in, &confirm, withStdio) + err = testable.AskOne(in, &confirm, withStdio) if err != nil { return err } diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index 1d41380c..f43818bc 100644 --- a/pkg/granted/credentials.go +++ b/pkg/granted/credentials.go @@ -376,7 +376,7 @@ If you have already used 'granted credentials export-plaintext ' t Message: "Are you sure you want to remove these credentials and profile from your AWS config?", Default: true, } - err = survey.AskOne(s, &confirm) + err = testable.AskOne(s, &confirm) if err != nil { return err } diff --git a/pkg/granted/registry/gitregistry/config_yaml.go b/pkg/granted/registry/gitregistry/config_yaml.go index 1af80361..45be56c3 100644 --- a/pkg/granted/registry/gitregistry/config_yaml.go +++ b/pkg/granted/registry/gitregistry/config_yaml.go @@ -9,6 +9,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/testable" "gopkg.in/yaml.v3" ) @@ -126,7 +127,7 @@ func (c ConfigYAML) PromptRequiredKeys(passedKeys []string, interactive bool, re if len(questions) > 0 { clio.Info("Your Profile Registry requires you to input values for the following keys:") - err = survey.Ask(questions, &ansmap, withStdio) + err = testable.Ask(questions, &ansmap, withStdio) if err != nil { return err } diff --git a/pkg/granted/registry/setup.go b/pkg/granted/registry/setup.go index bdb0b008..3933fe33 100644 --- a/pkg/granted/registry/setup.go +++ b/pkg/granted/registry/setup.go @@ -8,6 +8,7 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" "github.com/fwdcloudsec/granted/pkg/git" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" ) @@ -44,7 +45,7 @@ var SetupCommand = cli.Command{ Message: "Are you sure you want to copy all of the profiles from your AWS config file?", Default: true, } - err = survey.AskOne(s, &confirm) + err = testable.AskOne(s, &confirm) if err != nil { return err } diff --git a/pkg/granted/settings/requesturl/set.go b/pkg/granted/settings/requesturl/set.go index 12941370..e652f812 100644 --- a/pkg/granted/settings/requesturl/set.go +++ b/pkg/granted/settings/requesturl/set.go @@ -7,6 +7,7 @@ import ( "github.com/AlecAivazis/survey/v2" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/pkg/errors" "github.com/urfave/cli/v2" ) @@ -28,7 +29,7 @@ var setRequestURLCommand = cli.Command{ Help: "URL for your Common Fate dashboard from where users can request access \n for e.g: https://example.com", } withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - err := survey.AskOne(in, &approvalsURL, withStdio) + err := testable.AskOne(in, &approvalsURL, withStdio) if err != nil { return err } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index ebe85955..bd887868 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -9,6 +9,7 @@ import ( "github.com/common-fate/clio" "github.com/common-fate/grab" "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" ) @@ -49,7 +50,7 @@ var SetConfigCommand = cli.Command{ Message: "Select the configuration to change", Options: fields, } - err = survey.AskOne(p, &selectedFieldName) + err = testable.AskOne(p, &selectedFieldName) if err != nil { return err } @@ -71,7 +72,7 @@ var SetConfigCommand = cli.Command{ Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), Default: selectedField.Value().(bool), } - err = survey.AskOne(prompt, &value) + err = testable.AskOne(prompt, &value) if err != nil { return err } @@ -90,7 +91,7 @@ var SetConfigCommand = cli.Command{ Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), Default: selectedField.Value().(string), } - err = survey.AskOne(prompt, &str) + err = testable.AskOne(prompt, &str) if err != nil { return err } @@ -104,7 +105,7 @@ var SetConfigCommand = cli.Command{ Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), Default: fmt.Sprintf("%v", selectedField.Value()), } - err = survey.AskOne(prompt, &value) + err = testable.AskOne(prompt, &value) if err != nil { return err } diff --git a/pkg/granted/uninstall.go b/pkg/granted/uninstall.go index cc80a90f..04847c25 100644 --- a/pkg/granted/uninstall.go +++ b/pkg/granted/uninstall.go @@ -7,6 +7,7 @@ import ( "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/alias" "github.com/fwdcloudsec/granted/pkg/config" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" ) @@ -20,7 +21,7 @@ var UninstallCommand = cli.Command{ Default: true, } var confirm bool - err := survey.AskOne(in, &confirm, withStdio) + err := testable.AskOne(in, &confirm, withStdio) if err != nil { return err } diff --git a/pkg/testable/testable.go b/pkg/testable/testable.go index a0426ed9..af15262e 100644 --- a/pkg/testable/testable.go +++ b/pkg/testable/testable.go @@ -57,6 +57,20 @@ func AskOne(in survey.Prompt, out interface{}, opts ...survey.AskOpt) error { return survey.AskOne(in, out, opts...) } +// Ask is the multi-question counterpart to AskOne. In testing mode it consumes +// one input from the test stream per question, keyed by the question's name. +func Ask(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { + if isTesting { + for _, q := range qs { + if err := core.WriteAnswer(response, q.Name, nextSurveyInput()); err != nil { + return err + } + } + return nil + } + return survey.Ask(qs, response, opts...) +} + func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { if isTesting { validateNextOutput(format, a...) From 2198e037c726ee4367bf1cb02874dd9d470743f0 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:16 -0400 Subject: [PATCH 02/18] chore: add huh v2 dependency --- go.mod | 29 +++++++++++++++++++++---- go.sum | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 3c999865..d8c65602 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/fwdcloudsec/granted -go 1.25.7 +go 1.25.8 require ( github.com/99designs/keyring v1.2.2 @@ -17,10 +17,12 @@ require ( ) require ( + charm.land/huh/v2 v2.0.3 github.com/alessio/shellescape v1.4.2 github.com/common-fate/clio v1.2.3 github.com/common-fate/grab v1.3.0 github.com/fatih/color v1.16.0 + github.com/google/uuid v1.6.0 github.com/hashicorp/go-version v1.7.0 github.com/schollz/progressbar/v3 v3.13.1 go.uber.org/zap v1.26.0 @@ -28,26 +30,45 @@ require ( ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.2 // indirect + charm.land/lipgloss/v2 v2.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect github.com/Masterminds/sprig/v3 v3.2.3 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect github.com/benbjohnson/clock v1.3.5 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/huandu/xstrings v1.3.3 // indirect github.com/imdario/mergo v0.3.11 // indirect github.com/kr/pretty v0.3.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.0.0 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.0 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.3.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.10.0 // indirect @@ -87,7 +108,7 @@ require ( github.com/stretchr/testify v1.10.0 go.uber.org/ratelimit v0.3.0 golang.org/x/sync v0.19.0 - golang.org/x/sys v0.41.0 + golang.org/x/sys v0.42.0 golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 gopkg.in/ini.v1 v1.67.0 diff --git a/go.sum b/go.sum index 3751100a..d249fa8d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,11 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= +charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU= +charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc= +charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= +charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= @@ -6,6 +14,8 @@ github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkk github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7YgDP83g= @@ -16,6 +26,8 @@ github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63n github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +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-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA= @@ -44,8 +56,40 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw= github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= +github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +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/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +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/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= +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-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= +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/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +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/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/common-fate/awsconfigfile v0.10.0 h1:9W0JTeO0d3jNLw3Ps9U7IJwLYp4D9zcipq/sqNEWJOg= github.com/common-fate/awsconfigfile v0.10.0/go.mod h1:znstvN26aO+KUwmdjwZ+WcmitZ7heEJb5iFdCPokAO8= github.com/common-fate/clio v1.2.3 h1:hHwUYZjn66qGYDpgANl0EB/92hyi/Jsnd07qB09rvn4= @@ -55,14 +99,17 @@ github.com/common-fate/grab v1.3.0/go.mod h1:6zH8GckZGFrOKfZzL4Y/2OTvxwFeL6cDtsz github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/dvsekhvalnov/jose2go v1.8.0 h1:LqkkVKAlHFfH9LOEl5fe4p/zL02OhWE7pCufMBG2jLA= github.com/dvsekhvalnov/jose2go v1.8.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -100,6 +147,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -110,8 +159,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= @@ -119,10 +168,14 @@ github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2Em github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +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/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY= github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= @@ -162,6 +215,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= github.com/urfave/cli/v2 v2.25.7/go.mod h1:8qnjx1vcq5s2/wpsqoZFndg2CE5tNFyrTvS6SinrnYQ= +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= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -180,6 +235,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -200,8 +257,8 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= From 469441f220d33886a8406e365e5fa7051fa86175 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:16 -0400 Subject: [PATCH 03/18] refactor(testable): introduce Prompter interface with huh-backed Confirm Adds the Prompter interface and a testable.Confirm function as the entry point for migrated callers. The huh implementation writes to os.Stderr (granted's stdout is shell-evaluated) and binds ESC alongside Ctrl+C to the form's Quit action, since huh v2 binds Ctrl+C only. --- pkg/testable/prompter.go | 74 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 pkg/testable/prompter.go diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go new file mode 100644 index 00000000..0271a15b --- /dev/null +++ b/pkg/testable/prompter.go @@ -0,0 +1,74 @@ +package testable + +import ( + "fmt" + "io" + "os" + "strconv" + + "charm.land/huh/v2" +) + +// Prompter is the interactive-prompt abstraction used by the testable +// package's top-level prompt functions. Implementations write to os.Stderr +// by default because granted's stdout is shell-evaluated. +type Prompter interface { + Confirm(message string, defaultValue bool) (bool, error) +} + +var ( + defaultPrompter Prompter = newHuhPrompter() + huhKeyMap *huh.KeyMap +) + +func init() { + // huh v2's default Quit binding is ctrl+c only; extend it so ESC also + // cancels the prompt, which matches what users expect from modal pickers. + huhKeyMap = huh.NewDefaultKeyMap() + huhKeyMap.Quit.SetKeys("ctrl+c", "esc") + huhKeyMap.Quit.SetHelp("esc/ctrl+c", "cancel") +} + +// Confirm shows a yes/no prompt. In testing mode it consumes one value +// from the input stream configured by WithNextSurveyInputFunc. +func Confirm(message string, defaultValue bool) (bool, error) { + return defaultPrompter.Confirm(message, defaultValue) +} + +type huhPrompter struct { + stdin io.Reader + stdout io.Writer +} + +func newHuhPrompter() *huhPrompter { + return &huhPrompter{stdin: os.Stdin, stdout: os.Stderr} +} + +func (h *huhPrompter) Confirm(message string, defaultValue bool) (bool, error) { + if isTesting { + return testInputAsBool() + } + ans := defaultValue + err := huh.NewForm( + huh.NewGroup( + huh.NewConfirm().Title(message).Value(&ans), + ), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + return ans, err +} + +func testInputAsBool() (bool, error) { + v := nextSurveyInput() + switch x := v.(type) { + case bool: + return x, nil + case string: + b, err := strconv.ParseBool(x) + if err != nil { + return false, fmt.Errorf("testable.Confirm: cannot parse %q as bool: %w", x, err) + } + return b, nil + default: + return false, fmt.Errorf("testable.Confirm: unexpected test input type %T", v) + } +} From 671e3991132bf41a95c2197033a08c89376ce402 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:16 -0400 Subject: [PATCH 04/18] refactor(cfaws): migrate env.go to testable.Confirm --- pkg/cfaws/env.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg/cfaws/env.go b/pkg/cfaws/env.go index 072bd73b..38b08619 100644 --- a/pkg/cfaws/env.go +++ b/pkg/cfaws/env.go @@ -5,7 +5,6 @@ import ( "os" - "github.com/AlecAivazis/survey/v2" "github.com/aws/aws-sdk-go-v2/aws" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/testable" @@ -15,10 +14,8 @@ import ( // WriteCredentialsToDotenv will check if a .env file exists and prompt to create one if it does not. // After the file exists, it will be opened, credentaisl added and then written to disc func WriteCredentialsToDotenv(region string, creds aws.Credentials) error { - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) if _, err := os.Stat("./.env"); os.IsNotExist(err) { - ans := false - err = testable.AskOne(&survey.Confirm{Message: "No .env file found in the current directory, would you like to create one?"}, &ans, withStdio) + ans, err := testable.Confirm("No .env file found in the current directory, would you like to create one?", false) if err != nil { return err } From e51ca333ea31a103e41277c8dafd1364c37b7905 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 05/18] refactor(testable): add Prompter.Select with huh-backed implementation Uses generic huh.Select[string] and inherits the ESC-cancel keybinding from the form keymap. --- pkg/testable/prompter.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index 0271a15b..e53d80db 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -14,6 +14,7 @@ import ( // by default because granted's stdout is shell-evaluated. type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) + Select(message string, options []string) (string, error) } var ( @@ -35,6 +36,12 @@ func Confirm(message string, defaultValue bool) (bool, error) { return defaultPrompter.Confirm(message, defaultValue) } +// Select shows a single-choice list. In testing mode it consumes one value +// from the input stream configured by WithNextSurveyInputFunc. +func Select(message string, options []string) (string, error) { + return defaultPrompter.Select(message, options) +} + type huhPrompter struct { stdin io.Reader stdout io.Writer @@ -57,6 +64,30 @@ func (h *huhPrompter) Confirm(message string, defaultValue bool) (bool, error) { return ans, err } +func (h *huhPrompter) Select(message string, options []string) (string, error) { + if isTesting { + return testInputAsString(), nil + } + var ans string + err := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(message). + Options(huh.NewOptions(options...)...). + Value(&ans), + ), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + return ans, err +} + +func testInputAsString() string { + v := nextSurveyInput() + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + func testInputAsBool() (bool, error) { v := nextSurveyInput() switch x := v.(type) { From a04d223b115bedc316734190301d9b17b7512d2d Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 06/18] refactor: migrate simple survey.Select callers to testable.Select Replaces twelve survey.Select sites that used only Message and Options. Each migration drops the survey.WithStdio boilerplate since the Prompter handles stderr internally. One adjacent Confirm in browser/detect.go is migrated as well because it shared the now-removed withStdio variable. --- pkg/browser/detect.go | 26 ++++++-------------------- pkg/granted/credentials.go | 12 ++++-------- pkg/granted/registry/add.go | 5 +---- pkg/granted/registry/remove.go | 5 +---- pkg/granted/registry/sync.go | 7 +------ pkg/granted/settings/frecency.go | 10 +--------- pkg/granted/settings/set.go | 6 +----- pkg/granted/tokens.go | 10 +--------- 8 files changed, 16 insertions(+), 65 deletions(-) diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index 28bfeda0..51bfc6a6 100644 --- a/pkg/browser/detect.go +++ b/pkg/browser/detect.go @@ -47,14 +47,11 @@ func UserHasDefaultBrowser(ctx *cli.Context) (bool, error) { func HandleManualBrowserSelection() (string, error) { // didn't find it, request manual input - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := survey.Select{ - Message: "Select one of the browsers from the list", - Options: []string{"Chrome", "Brave", "Edge", "Vivaldi", "Firefox", "Waterfox", "Chromium", "Safari", "Stdout", "FirefoxStdout", "Firefox Developer Edition", "Firefox Nightly", "Arc", "Zen", "Custom"}, - } - var selection string clio.NewLine() - err := testable.AskOne(&in, &selection, withStdio) + selection, err := testable.Select( + "Select one of the browsers from the list", + []string{"Chrome", "Brave", "Edge", "Vivaldi", "Firefox", "Waterfox", "Chromium", "Safari", "Stdout", "FirefoxStdout", "Firefox Developer Edition", "Firefox Nightly", "Arc", "Zen", "Custom"}, + ) if err != nil { return "", err } @@ -342,14 +339,8 @@ func RunFirefoxExtensionPrompts(browserPath string, browserName string) error { label := fmt.Sprintf("Open %s to download the extension?", browserName) - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := &survey.Select{ - Message: label, - Options: []string{"Yes", "Already installed", "No"}, - } - var out string clio.NewLine() - err := testable.AskOne(in, &out, withStdio) + out, err := testable.Select(label, []string{"Yes", "Already installed", "No"}) if err != nil { return err } @@ -376,13 +367,8 @@ func RunFirefoxExtensionPrompts(browserPath string, browserName string) error { return err } time.Sleep(time.Second * 2) - confIn := &survey.Confirm{ - Message: "Type Y to continue once you have installed the extension", - Default: true, - } - var confirm bool clio.NewLine() - err = testable.AskOne(confIn, &confirm, withStdio) + confirm, err := testable.Confirm("Type Y to continue once you have installed the extension", true) if err != nil { return err } diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index f43818bc..50629894 100644 --- a/pkg/granted/credentials.go +++ b/pkg/granted/credentials.go @@ -274,8 +274,7 @@ var UpdateCredentialsCommand = cli.Command{ fmt.Println("No credentials in secure storage") return nil } - in := survey.Select{Message: "Profile Name:", Options: profileNames} - err = testable.AskOne(&in, &profileName) + profileName, err = testable.Select("Profile Name:", profileNames) if err != nil { return err } @@ -357,8 +356,7 @@ var RemoveCredentialsCommand = cli.Command{ return nil } if profileName == "" { - in := survey.Select{Message: "Profile Name:", Options: secureProfileKeys} - err = testable.AskOne(&in, &profileName) + profileName, err = testable.Select("Profile Name:", secureProfileKeys) if err != nil { return err } @@ -434,8 +432,7 @@ var ExportCredentialsCommand = cli.Command{ } if profileName == "" { - in := survey.Select{Message: "Profile Name:", Options: secureProfileKeys} - err = testable.AskOne(&in, &profileName) + profileName, err = testable.Select("Profile Name:", secureProfileKeys) if err != nil { return err } @@ -537,8 +534,7 @@ var RotateCredentialsCommand = cli.Command{ fmt.Println("No credentials in secure storage") return nil } - in := survey.Select{Message: "Profile Name:", Options: profileNames} - err = testable.AskOne(&in, &profileName) + profileName, err = testable.Select("Profile Name:", profileNames) if err != nil { return err } diff --git a/pkg/granted/registry/add.go b/pkg/granted/registry/add.go index e1d8778b..7a5c86cd 100644 --- a/pkg/granted/registry/add.go +++ b/pkg/granted/registry/add.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" @@ -127,9 +126,7 @@ var AddCommand = cli.Command{ options := []string{DUPLICATE, ABORT} - in := survey.Select{Message: "Please select which option would you like to choose to resolve: ", Options: options} - var selected string - err = testable.AskOne(&in, &selected) + selected, err := testable.Select("Please select which option would you like to choose to resolve: ", options) if err != nil { return err } diff --git a/pkg/granted/registry/remove.go b/pkg/granted/registry/remove.go index 26c7e14e..ff7733b2 100644 --- a/pkg/granted/registry/remove.go +++ b/pkg/granted/registry/remove.go @@ -1,7 +1,6 @@ package registry import ( - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" @@ -32,9 +31,7 @@ var RemoveCommand = cli.Command{ registriesWithNames = append(registriesWithNames, r.Name) } - in := survey.Select{Message: "Please select the git repository you would like to unsubscribe:", Options: registriesWithNames} - var out string - err = testable.AskOne(&in, &out) + out, err := testable.Select("Please select the git repository you would like to unsubscribe:", registriesWithNames) if err != nil { return err } diff --git a/pkg/granted/registry/sync.go b/pkg/granted/registry/sync.go index ea16663b..0d76ab52 100644 --- a/pkg/granted/registry/sync.go +++ b/pkg/granted/registry/sync.go @@ -4,9 +4,7 @@ import ( "context" "errors" "fmt" - "os" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/granted/awsmerge" "github.com/fwdcloudsec/granted/pkg/testable" @@ -77,10 +75,7 @@ func SyncProfileRegistries(ctx context.Context, interactive bool) error { options := []string{DUPLICATE, ABORT} - in := survey.Select{Message: "Please select which option would you like to choose to resolve: ", Options: options} - var selected string - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - err = testable.AskOne(&in, &selected, withStdio) + selected, err := testable.Select("Please select which option would you like to choose to resolve: ", options) if err != nil { return err } diff --git a/pkg/granted/settings/frecency.go b/pkg/granted/settings/frecency.go index 9f650e78..71451562 100644 --- a/pkg/granted/settings/frecency.go +++ b/pkg/granted/settings/frecency.go @@ -2,9 +2,7 @@ package settings import ( "fmt" - "os" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" @@ -33,14 +31,8 @@ var SetProfileOrderingCommand = cli.Command{ if err != nil { return err } - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := survey.Select{ - Message: "Select filter type", - Options: []string{"Frecency", "Alphabetical"}, - } - var selection string clio.NewLine() - err = testable.AskOne(&in, &selection, withStdio) + selection, err := testable.Select("Select filter type", []string{"Frecency", "Alphabetical"}) if err != nil { return err } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index bd887868..b8642d64 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -46,11 +46,7 @@ var SetConfigCommand = cli.Command{ var selectedFieldName = c.String("setting") if selectedFieldName == "" { - p := &survey.Select{ - Message: "Select the configuration to change", - Options: fields, - } - err = testable.AskOne(p, &selectedFieldName) + selectedFieldName, err = testable.Select("Select the configuration to change", fields) if err != nil { return err } diff --git a/pkg/granted/tokens.go b/pkg/granted/tokens.go index 31f7866c..520e9080 100644 --- a/pkg/granted/tokens.go +++ b/pkg/granted/tokens.go @@ -5,11 +5,9 @@ import ( "encoding/json" "errors" "fmt" - "os" "strings" "time" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/cfaws" "github.com/fwdcloudsec/granted/pkg/securestorage" @@ -226,14 +224,8 @@ var ClearSSOTokensCommand = cli.Command{ tokenList = append(tokenList, stringKey) selectionsMap[stringKey] = k } - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := survey.Select{ - Message: "Select a token to remove from keyring", - Options: tokenList, - } clio.NewLine() - var out string - err = testable.AskOne(&in, &out, withStdio) + out, err := testable.Select("Select a token to remove from keyring", tokenList) if err != nil { return err } From f689ec950576a265bac6668b276da88339bfa701 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 07/18] refactor(testable): add Prompter.Input with huh-backed implementation --- pkg/testable/prompter.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index e53d80db..207dc929 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -15,6 +15,7 @@ import ( type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) Select(message string, options []string) (string, error) + Input(message string, defaultValue string) (string, error) } var ( @@ -42,6 +43,12 @@ func Select(message string, options []string) (string, error) { return defaultPrompter.Select(message, options) } +// Input shows a free-form text prompt. In testing mode it consumes one value +// from the input stream configured by WithNextSurveyInputFunc. +func Input(message string, defaultValue string) (string, error) { + return defaultPrompter.Input(message, defaultValue) +} + type huhPrompter struct { stdin io.Reader stdout io.Writer @@ -80,6 +87,19 @@ func (h *huhPrompter) Select(message string, options []string) (string, error) { return ans, err } +func (h *huhPrompter) Input(message string, defaultValue string) (string, error) { + if isTesting { + return testInputAsString(), nil + } + ans := defaultValue + err := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title(message).Value(&ans), + ), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + return ans, err +} + func testInputAsString() string { v := nextSurveyInput() if v == nil { From 7750db618f85a15d1450564b90e060ebcf192fb9 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 08/18] refactor: migrate simple survey.Input callers to testable.Input Replaces twelve survey.Input sites. The settings/set.go Int case keeps the existing string-through-interface{} path (no behaviour change). --- pkg/browser/detect.go | 10 ++++------ pkg/cfaws/creds.go | 7 +------ pkg/granted/credentials.go | 3 +-- pkg/granted/registry/migrate.go | 11 +++-------- pkg/granted/settings/export.go | 9 +-------- pkg/granted/settings/set.go | 14 ++++---------- pkg/granted/sso.go | 9 ++++----- 7 files changed, 18 insertions(+), 45 deletions(-) diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index 51bfc6a6..9e8e2174 100644 --- a/pkg/browser/detect.go +++ b/pkg/browser/detect.go @@ -220,7 +220,6 @@ func HandleBrowserWizard(ctx *cli.Context) (string, error) { // ConfigureBrowserSelection will verify the existance of the browser executable and promot for a path if it cannot be found func ConfigureBrowserSelection(browserName string, path string) error { browserKey := GetBrowserKey(browserName) - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) title := cases.Title(language.AmericanEnglish) browserTitle := title.String(strings.ToLower(browserKey)) // We allow users to configure a custom install path if we cannot detect the installation @@ -247,9 +246,9 @@ func ConfigureBrowserSelection(browserName string, path string) error { validPath := false for !validPath { // prompt for custom path - bpIn := survey.Input{Message: fmt.Sprintf("Please enter the full path to your browser installation for %s:", browserTitle)} clio.NewLine() - err := testable.AskOne(&bpIn, &customBrowserPath, withStdio) + var err error + customBrowserPath, err = testable.Input(fmt.Sprintf("Please enter the full path to your browser installation for %s:", browserTitle), "") if err != nil { return err } @@ -387,7 +386,6 @@ func AskAndGetBrowserPath() (string, error) { } browserKey := GetBrowserKey(outcome) - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) title := cases.Title(language.AmericanEnglish) browserTitle := title.String(strings.ToLower(browserKey)) // We allow users to configure a custom install path is we cannot detect the installation @@ -402,9 +400,9 @@ func AskAndGetBrowserPath() (string, error) { validPath := false for !validPath { // prompt for custom path - bpIn := survey.Input{Message: fmt.Sprintf("Please enter the full path to your browser installation for %s:", browserTitle)} clio.NewLine() - err := testable.AskOne(&bpIn, &customBrowserPath, withStdio) + var err error + customBrowserPath, err = testable.Input(fmt.Sprintf("Please enter the full path to your browser installation for %s:", browserTitle), "") if err != nil { return "", err } diff --git a/pkg/cfaws/creds.go b/pkg/cfaws/creds.go index bca8513a..a4e9da2d 100644 --- a/pkg/cfaws/creds.go +++ b/pkg/cfaws/creds.go @@ -6,7 +6,6 @@ import ( "os" "time" - "github.com/AlecAivazis/survey/v2" "github.com/aws/aws-sdk-go-v2/aws" ssotypes "github.com/aws/aws-sdk-go-v2/service/sso/types" "github.com/aws/aws-sdk-go-v2/service/sts" @@ -122,9 +121,5 @@ func GetCredentialsCreds(ctx context.Context, c *Profile) (aws.Credentials, erro } func MfaTokenProvider() (string, error) { - in := survey.Input{Message: "MFA Token"} - var out string - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - err := testable.AskOne(&in, &out, withStdio) - return out, err + return testable.Input("MFA Token", "") } diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index 50629894..2a30540d 100644 --- a/pkg/granted/credentials.go +++ b/pkg/granted/credentials.go @@ -244,8 +244,7 @@ var ImportCredentialsCommand = cli.Command{ } func promptCredentials() (credentials aws.Credentials, err error) { - in1 := survey.Input{Message: "Access Key ID:"} - err = testable.AskOne(&in1, &credentials.AccessKeyID) + credentials.AccessKeyID, err = testable.Input("Access Key ID:", "") if err != nil { return } diff --git a/pkg/granted/registry/migrate.go b/pkg/granted/registry/migrate.go index b3c29eae..9ebd1dbb 100644 --- a/pkg/granted/registry/migrate.go +++ b/pkg/granted/registry/migrate.go @@ -3,7 +3,6 @@ package registry import ( "fmt" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" @@ -26,15 +25,11 @@ var MigrateCommand = cli.Command{ if len(gConf.ProfileRegistryURLS) > 0 { var registries []grantedConfig.Registry for i, u := range gConf.ProfileRegistryURLS { - var msg survey.Input + defaultName := "granted-registry" if i > 0 { - msg = survey.Input{Message: fmt.Sprintf("Enter a registry name for %s", u), Default: fmt.Sprintf("granted-registry-%d", i)} - } else { - msg = survey.Input{Message: fmt.Sprintf("Enter a registry name for %s", u), Default: "granted-registry"} + defaultName = fmt.Sprintf("granted-registry-%d", i) } - - var selected string - err := testable.AskOne(&msg, &selected) + selected, err := testable.Input(fmt.Sprintf("Enter a registry name for %s", u), defaultName) if err != nil { return err } diff --git a/pkg/granted/settings/export.go b/pkg/granted/settings/export.go index cb102fd4..2d79ddf3 100644 --- a/pkg/granted/settings/export.go +++ b/pkg/granted/settings/export.go @@ -2,9 +2,7 @@ package settings import ( "fmt" - "os" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" @@ -35,13 +33,8 @@ var SetExportSettingsCommand = cli.Command{ if err != nil { return err } - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := survey.Input{ - Message: "Exported credential suffix:", - } - var selection string clio.NewLine() - err = testable.AskOne(&in, &selection, withStdio) + selection, err := testable.Input("Exported credential suffix:", "") if err != nil { return err } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index b8642d64..a98b4433 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -83,11 +83,7 @@ var SetConfigCommand = cli.Command{ case reflect.String: if !c.IsSet("value") { var str string - prompt = &survey.Input{ - Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), - Default: selectedField.Value().(string), - } - err = testable.AskOne(prompt, &str) + str, err = testable.Input(fmt.Sprintf("Enter new value for %s:", selectedFieldName), selectedField.Value().(string)) if err != nil { return err } @@ -97,14 +93,12 @@ var SetConfigCommand = cli.Command{ } case reflect.Int: if !c.IsSet("value") { - prompt = &survey.Input{ - Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), - Default: fmt.Sprintf("%v", selectedField.Value()), - } - err = testable.AskOne(prompt, &value) + var str string + str, err = testable.Input(fmt.Sprintf("Enter new value for %s:", selectedFieldName), fmt.Sprintf("%v", selectedField.Value())) if err != nil { return err } + value = str } else { valueInt := c.String("value") value, err = strconv.Atoi(valueInt) diff --git a/pkg/granted/sso.go b/pkg/granted/sso.go index d70ee66e..ecba01c9 100644 --- a/pkg/granted/sso.go +++ b/pkg/granted/sso.go @@ -11,7 +11,6 @@ import ( "os" "regexp" - "github.com/AlecAivazis/survey/v2" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/aws/ratelimit" "github.com/aws/aws-sdk-go-v2/aws/retry" @@ -261,8 +260,8 @@ var LoginCommand = cli.Command{ ssoStartUrl := c.String("sso-start-url") if ssoStartUrl == "" { - in1 := survey.Input{Message: "SSO Start URL"} - err := testable.AskOne(&in1, &ssoStartUrl) + var err error + ssoStartUrl, err = testable.Input("SSO Start URL", "") if err != nil { return err } @@ -292,8 +291,8 @@ var LoginCommand = cli.Command{ // Fallback to user input if ssoRegion == "" { - in2 := survey.Input{Message: "Region"} - err := testable.AskOne(&in2, &ssoRegion) + var err error + ssoRegion, err = testable.Input("Region", "") if err != nil { return err } From b5bc940f32c4897ccd7ddbf24dc042b80a8603d1 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 09/18] refactor(testable): add Prompter.Password with huh-backed implementation --- pkg/testable/prompter.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index 207dc929..2d50efdb 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -16,6 +16,7 @@ type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) Select(message string, options []string) (string, error) Input(message string, defaultValue string) (string, error) + Password(message string) (string, error) } var ( @@ -49,6 +50,12 @@ func Input(message string, defaultValue string) (string, error) { return defaultPrompter.Input(message, defaultValue) } +// Password shows a masked-input prompt. In testing mode it consumes one value +// from the input stream configured by WithNextSurveyInputFunc. +func Password(message string) (string, error) { + return defaultPrompter.Password(message) +} + type huhPrompter struct { stdin io.Reader stdout io.Writer @@ -100,6 +107,19 @@ func (h *huhPrompter) Input(message string, defaultValue string) (string, error) return ans, err } +func (h *huhPrompter) Password(message string) (string, error) { + if isTesting { + return testInputAsString(), nil + } + var ans string + err := huh.NewForm( + huh.NewGroup( + huh.NewInput().Title(message).EchoMode(huh.EchoModePassword).Value(&ans), + ), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + return ans, err +} + func testInputAsString() string { v := nextSurveyInput() if v == nil { From 4a4ad1066409bbf351584571945cf2ffc0f9ce08 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:25 -0400 Subject: [PATCH 10/18] refactor: migrate Confirm callers and the lone Password caller Migrates the secret-access-key Password prompt and six Confirm prompts (alias install, default-browser-firefox, uninstall config, credentials removal, settings/set Bool case, registry setup). Drops the var prompt survey.Prompt declaration in settings/set.go since none of the cases reference it anymore. --- pkg/alias/alias.go | 9 +-------- pkg/browser/detect.go | 8 +------- pkg/granted/credentials.go | 10 ++-------- pkg/granted/registry/setup.go | 8 +------- pkg/granted/settings/set.go | 10 +++------- pkg/granted/uninstall.go | 9 +-------- 6 files changed, 9 insertions(+), 45 deletions(-) diff --git a/pkg/alias/alias.go b/pkg/alias/alias.go index 719f97be..2964c7e1 100644 --- a/pkg/alias/alias.go +++ b/pkg/alias/alias.go @@ -14,7 +14,6 @@ import ( "path/filepath" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/internal/build" "github.com/fwdcloudsec/granted/pkg/shells" @@ -198,13 +197,7 @@ func SetupShellWizard(autoConfigure bool) error { // skip prompt if autoConfigure is set to true if !autoConfigure { clio.Info("To assume roles with Granted, we need to add an alias to your shell profile (https://docs.commonfate.io/granted/internals/shell-alias)") - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := &survey.Confirm{ - Message: fmt.Sprintf("Install %s alias at %s", shell, cfg.File), - Default: true, - } - var confirm bool - err = testable.AskOne(in, &confirm, withStdio) + confirm, err := testable.Confirm(fmt.Sprintf("Install %s alias at %s", shell, cfg.File), true) if err != nil { return err } diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index 9e8e2174..e8dc8c1d 100644 --- a/pkg/browser/detect.go +++ b/pkg/browser/detect.go @@ -191,7 +191,6 @@ func DetectInstallation(browserKey string) (string, bool) { } func HandleBrowserWizard(ctx *cli.Context) (string, error) { - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) browserName, err := Find() if err != nil { return "", err @@ -201,12 +200,7 @@ func HandleBrowserWizard(ctx *cli.Context) (string, error) { clio.Info("Thanks for using Granted!") clio.Infof("By default, Granted will open the AWS console with this browser: %s", browserTitle) clio.Warn("Granted works best with Firefox but also supports Chrome, Brave, and Edge (https://docs.commonfate.io/granted/introduction#supported-browsers). You can change this setting later by running 'granted browser set'") - in := survey.Confirm{ - Message: "Use Firefox as default Granted browser?", - Default: true, - } - var confirm bool - err = testable.AskOne(&in, &confirm, withStdio) + confirm, err := testable.Confirm("Use Firefox as default Granted browser?", true) if err != nil { return "", err } diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index 2a30540d..5a81197f 100644 --- a/pkg/granted/credentials.go +++ b/pkg/granted/credentials.go @@ -248,8 +248,7 @@ func promptCredentials() (credentials aws.Credentials, err error) { if err != nil { return } - in2 := survey.Password{Message: "Secret Access Key:"} - err = testable.AskOne(&in2, &credentials.SecretAccessKey) + credentials.SecretAccessKey, err = testable.Password("Secret Access Key:") if err != nil { return } @@ -368,12 +367,7 @@ This command will remove a profile with the same name from the AWS config file i If you have already used 'granted credentials export-plaintext ' to export the credentials, the profile will not be removed by this command. `) - var confirm bool - s := &survey.Confirm{ - Message: "Are you sure you want to remove these credentials and profile from your AWS config?", - Default: true, - } - err = testable.AskOne(s, &confirm) + confirm, err := testable.Confirm("Are you sure you want to remove these credentials and profile from your AWS config?", true) if err != nil { return err } diff --git a/pkg/granted/registry/setup.go b/pkg/granted/registry/setup.go index 3933fe33..2bc0976c 100644 --- a/pkg/granted/registry/setup.go +++ b/pkg/granted/registry/setup.go @@ -4,7 +4,6 @@ import ( "os" "path" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/common-fate/clio/clierr" "github.com/fwdcloudsec/granted/pkg/git" @@ -40,12 +39,7 @@ var SetupCommand = cli.Command{ return err } - var confirm bool - s := &survey.Confirm{ - Message: "Are you sure you want to copy all of the profiles from your AWS config file?", - Default: true, - } - err = testable.AskOne(s, &confirm) + confirm, err := testable.Confirm("Are you sure you want to copy all of the profiles from your AWS config file?", true) if err != nil { return err } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index a98b4433..4dcd4d42 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -5,7 +5,6 @@ import ( "reflect" "strconv" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/common-fate/grab" "github.com/fwdcloudsec/granted/pkg/config" @@ -59,19 +58,16 @@ var SetConfigCommand = cli.Command{ } // Prompt the user to update the field var value interface{} - var prompt survey.Prompt switch selectedField.Kind() { case reflect.Bool: if !c.IsSet("value") { - prompt = &survey.Confirm{ - Message: fmt.Sprintf("Enter new value for %s:", selectedFieldName), - Default: selectedField.Value().(bool), - } - err = testable.AskOne(prompt, &value) + var b bool + b, err = testable.Confirm(fmt.Sprintf("Enter new value for %s:", selectedFieldName), selectedField.Value().(bool)) if err != nil { return err } + value = b } else { valueStr := c.String("value") value, err = strconv.ParseBool(valueStr) diff --git a/pkg/granted/uninstall.go b/pkg/granted/uninstall.go index 04847c25..d7c383f8 100644 --- a/pkg/granted/uninstall.go +++ b/pkg/granted/uninstall.go @@ -3,7 +3,6 @@ package granted import ( "os" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/alias" "github.com/fwdcloudsec/granted/pkg/config" @@ -15,13 +14,7 @@ var UninstallCommand = cli.Command{ Name: "uninstall", Usage: "Remove all Granted configuration", Action: func(c *cli.Context) error { - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := &survey.Confirm{ - Message: "Are you sure you want to remove your Granted config?", - Default: true, - } - var confirm bool - err := testable.AskOne(in, &confirm, withStdio) + confirm, err := testable.Confirm("Are you sure you want to remove your Granted config?", true) if err != nil { return err } From 160d4ad39cc72ec5498cd70f176140c4046612a9 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:36 -0400 Subject: [PATCH 11/18] refactor(testable): add validator-accepting Input and Select methods Adds InputWithValidator and SelectWithValidator to the Prompter interface, plus a Required helper for the common non-empty case. --- pkg/testable/prompter.go | 63 ++++++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index 2d50efdb..01a078f3 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -1,6 +1,7 @@ package testable import ( + "errors" "fmt" "io" "os" @@ -15,10 +16,20 @@ import ( type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) Select(message string, options []string) (string, error) + SelectWithValidator(message string, options []string, validate func(string) error) (string, error) Input(message string, defaultValue string) (string, error) + InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) Password(message string) (string, error) } +// Required is a validator that rejects empty input. +var Required = func(s string) error { + if s == "" { + return errors.New("response cannot be empty") + } + return nil +} + var ( defaultPrompter Prompter = newHuhPrompter() huhKeyMap *huh.KeyMap @@ -44,12 +55,26 @@ func Select(message string, options []string) (string, error) { return defaultPrompter.Select(message, options) } +// SelectWithValidator shows a single-choice list that re-prompts until the +// validator accepts the selection. In testing mode the validator is ignored +// and one value is consumed from the test input stream. +func SelectWithValidator(message string, options []string, validate func(string) error) (string, error) { + return defaultPrompter.SelectWithValidator(message, options, validate) +} + // Input shows a free-form text prompt. In testing mode it consumes one value // from the input stream configured by WithNextSurveyInputFunc. func Input(message string, defaultValue string) (string, error) { return defaultPrompter.Input(message, defaultValue) } +// InputWithValidator shows a free-form text prompt that re-prompts until the +// validator accepts the response. In testing mode the validator is ignored +// and one value is consumed from the test input stream. +func InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) { + return defaultPrompter.InputWithValidator(message, defaultValue, validate) +} + // Password shows a masked-input prompt. In testing mode it consumes one value // from the input stream configured by WithNextSurveyInputFunc. func Password(message string) (string, error) { @@ -79,30 +104,50 @@ func (h *huhPrompter) Confirm(message string, defaultValue bool) (bool, error) { } func (h *huhPrompter) Select(message string, options []string) (string, error) { + return h.selectWith(message, options, nil) +} + +func (h *huhPrompter) SelectWithValidator(message string, options []string, validate func(string) error) (string, error) { + return h.selectWith(message, options, validate) +} + +func (h *huhPrompter) selectWith(message string, options []string, validate func(string) error) (string, error) { if isTesting { return testInputAsString(), nil } var ans string + sel := huh.NewSelect[string](). + Title(message). + Options(huh.NewOptions(options...)...). + Value(&ans) + if validate != nil { + sel = sel.Validate(validate) + } err := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(message). - Options(huh.NewOptions(options...)...). - Value(&ans), - ), + huh.NewGroup(sel), ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() return ans, err } func (h *huhPrompter) Input(message string, defaultValue string) (string, error) { + return h.inputWith(message, defaultValue, nil) +} + +func (h *huhPrompter) InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) { + return h.inputWith(message, defaultValue, validate) +} + +func (h *huhPrompter) inputWith(message, defaultValue string, validate func(string) error) (string, error) { if isTesting { return testInputAsString(), nil } ans := defaultValue + in := huh.NewInput().Title(message).Value(&ans) + if validate != nil { + in = in.Validate(validate) + } err := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title(message).Value(&ans), - ), + huh.NewGroup(in), ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() return ans, err } From d6375c2df570a4b2532b0b4a72d4597a75bc1ba7 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:36 -0400 Subject: [PATCH 12/18] refactor: migrate validator-using survey callers - credentials.go AddCredentialsCommand: survey.MinLength(1) becomes testable.Required (equivalent semantics). - credentials.go ImportCredentialsCommand: custom validator now receives the selected string directly instead of a core.OptionAnswer. - config_yaml.go required-keys prompt: the survey.Ask multi-question form only ever held a single Question (var questions was declared inside the loop). Collapses to InputWithValidator + SaveKey, dropping the ansmap intermediary. The now-unused SaveKeys function is removed and SaveKey's stale doc comment is updated. --- pkg/granted/credentials.go | 17 +++--- .../registry/gitregistry/config_yaml.go | 57 ++++--------------- 2 files changed, 17 insertions(+), 57 deletions(-) diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index 5a81197f..18a0179f 100644 --- a/pkg/granted/credentials.go +++ b/pkg/granted/credentials.go @@ -6,8 +6,6 @@ import ( "os" "strings" - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/core" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" @@ -35,8 +33,8 @@ var AddCredentialsCommand = cli.Command{ Action: func(c *cli.Context) error { profileName := c.Args().First() if profileName == "" { - in := survey.Input{Message: "Profile Name:"} - err := testable.AskOne(&in, &profileName, survey.WithValidator(survey.MinLength(1))) + var err error + profileName, err = testable.InputWithValidator("Profile Name:", "", testable.Required) if err != nil { return err } @@ -150,12 +148,11 @@ var ImportCredentialsCommand = cli.Command{ } if profileName == "" { - in := survey.Select{Message: "Profile Name:", Options: profiles.ProfileNames} - err := testable.AskOne(&in, &profileName, survey.WithValidator(func(ans interface{}) error { - option := ans.(core.OptionAnswer) - // Not all profiles are valid for importing, so ensure this profile is suitable, and inform the user if it is not + the reason - return validateProfileForImport(c.Context, profiles, option.Value, c.Bool("overwrite")) - })) + // Not all profiles are valid for importing, so the validator ensures this profile + // is suitable, and informs the user if it is not + the reason + profileName, err = testable.SelectWithValidator("Profile Name:", profiles.ProfileNames, func(s string) error { + return validateProfileForImport(c.Context, profiles, s, c.Bool("overwrite")) + }) if err != nil { return err } diff --git a/pkg/granted/registry/gitregistry/config_yaml.go b/pkg/granted/registry/gitregistry/config_yaml.go index 45be56c3..939f57c2 100644 --- a/pkg/granted/registry/gitregistry/config_yaml.go +++ b/pkg/granted/registry/gitregistry/config_yaml.go @@ -6,7 +6,6 @@ import ( "path" "strings" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" @@ -64,7 +63,6 @@ func (c ConfigYAML) PromptRequiredKeys(passedKeys []string, interactive bool, re for fieldName, values := range v { if isRequiredKey(values) { - var questions []*survey.Question if len(passedKeys) != 0 { for _, val := range passedKeys { key, value, err := formatKey(val) @@ -114,31 +112,16 @@ func (c ConfigYAML) PromptRequiredKeys(passedKeys []string, interactive bool, re } } - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - - qs := survey.Question{ - Name: fieldName, - Prompt: &survey.Input{Message: fmt.Sprintf("'%s': %s", fieldName, prompt)}, - Validate: survey.Required} - - questions = append(questions, &qs) - ansmap := make(map[string]interface{}) - - if len(questions) > 0 { - clio.Info("Your Profile Registry requires you to input values for the following keys:") - - err = testable.Ask(questions, &ansmap, withStdio) - if err != nil { - return err - } - - err = SaveKeys(gConf, ansmap) - if err != nil { - return err - } - - break + clio.Info("Your Profile Registry requires you to input values for the following keys:") + value, err := testable.InputWithValidator(fmt.Sprintf("'%s': %s", fieldName, prompt), "", testable.Required) + if err != nil { + return err } + err = SaveKey(gConf, fieldName, value) + if err != nil { + return err + } + break } else { // for all other variables add them to registry as variables @@ -168,27 +151,7 @@ func (c ConfigYAML) PromptRequiredKeys(passedKeys []string, interactive bool, re return nil } -// This is used when user enters the required key through cli prompts. -func SaveKeys(gConf *grantedConfig.Config, ansmap map[string]interface{}) error { - for k, v := range ansmap { - if len(gConf.ProfileRegistry.RequiredKeys) == 0 { - var requiredKeys = make(map[string]string) - requiredKeys[k] = v.(string) - gConf.ProfileRegistry.RequiredKeys = requiredKeys - } else { - gConf.ProfileRegistry.RequiredKeys[k] = v.(string) - } - } - - err := gConf.Save() - if err != nil { - return err - } - - return nil -} - -// This is used when user passed the required value through flag. +// SaveKey persists a single required-key value to the granted config. func SaveKey(gConf *grantedConfig.Config, key string, value string) error { if len(gConf.ProfileRegistry.RequiredKeys) == 0 { var requiredKeys = make(map[string]string) From 56f4d808e1535a55353a6981d59e5f84637a2d50 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:36 -0400 Subject: [PATCH 13/18] refactor(testable): add SelectWithFilter using bubbles/list huh.Select's built-in filter is literal substring only with no public matcher hook; bubbles/list exposes a Filter field that takes a func, so the implementation drops one layer down to build the picker directly. The non-modal key handler mirrors survey's UX: typing appends to the filter, arrows navigate, Enter selects, Esc clears the filter or quits. The default bubbles/list keymap is replaced with bindings that match what's actually wired up. --- go.mod | 5 +- go.sum | 4 + pkg/testable/prompter.go | 1 + pkg/testable/select_with_filter.go | 165 +++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 pkg/testable/select_with_filter.go diff --git a/go.mod b/go.mod index d8c65602..8b3219d0 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ require ( ) require ( + charm.land/bubbles/v2 v2.0.0 + charm.land/bubbletea/v2 v2.0.2 charm.land/huh/v2 v2.0.3 github.com/alessio/shellescape v1.4.2 github.com/common-fate/clio v1.2.3 @@ -30,8 +32,6 @@ require ( ) require ( - charm.land/bubbles/v2 v2.0.0 // indirect - charm.land/bubbletea/v2 v2.0.2 // indirect charm.land/lipgloss/v2 v2.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver/v3 v3.2.0 // indirect @@ -66,6 +66,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/shopspring/decimal v1.2.0 // indirect github.com/spf13/cast v1.3.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/go.sum b/go.sum index d249fa8d..9fc84fac 100644 --- a/go.sum +++ b/go.sum @@ -147,6 +147,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= @@ -195,6 +197,8 @@ github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index 01a078f3..b347d5b3 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -17,6 +17,7 @@ type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) Select(message string, options []string) (string, error) SelectWithValidator(message string, options []string, validate func(string) error) (string, error) + SelectWithFilter(message string, options []string, filter func(term, option string) bool) (string, error) Input(message string, defaultValue string) (string, error) InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) Password(message string) (string, error) diff --git a/pkg/testable/select_with_filter.go b/pkg/testable/select_with_filter.go new file mode 100644 index 00000000..9e4a216e --- /dev/null +++ b/pkg/testable/select_with_filter.go @@ -0,0 +1,165 @@ +package testable + +import ( + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/list" + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" +) + +// SelectWithFilter shows a single-choice list with a custom filter function +// that decides which options match the user's typed text. Typing any +// printable character appends to the filter; arrows navigate; Enter selects; +// Esc clears the filter if any, otherwise cancels and returns huh.ErrUserAborted. +// +// In testing mode the filter is ignored and one value is consumed from the +// input stream configured by WithNextSurveyInputFunc. +func SelectWithFilter(message string, options []string, filter func(term, option string) bool) (string, error) { + return defaultPrompter.SelectWithFilter(message, options, filter) +} + +type filterItem struct{ value string } + +func (i filterItem) FilterValue() string { return i.value } +func (i filterItem) Title() string { return i.value } +func (i filterItem) Description() string { return "" } + +type selectFilterModel struct { + list list.Model + choice string + quitting bool +} + +func (m selectFilterModel) Init() tea.Cmd { return nil } + +func (m selectFilterModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.list.SetSize(msg.Width, msg.Height-2) + case tea.KeyPressMsg: + s := msg.String() + switch s { + case "ctrl+c": + m.quitting = true + return m, tea.Quit + case "enter": + if i, ok := m.list.SelectedItem().(filterItem); ok { + m.choice = i.value + } + return m, tea.Quit + case "esc": + // Esc clears the filter when one is active; otherwise quits. + if m.list.FilterValue() != "" { + m.list.ResetFilter() + return m, nil + } + m.quitting = true + return m, tea.Quit + case "up", "down", "pgup", "pgdown", "home", "end": + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd + case "backspace": + cur := m.list.FilterValue() + if len(cur) > 0 { + newFilter := cur[:len(cur)-1] + if newFilter == "" { + m.list.ResetFilter() + } else { + m.list.SetFilterText(newFilter) + } + } + return m, nil + } + // Any other printable single character appends to the filter, + // matching survey's type-to-filter behavior. + if len(s) == 1 && s[0] >= 0x20 && s[0] <= 0x7e { + m.list.SetFilterText(m.list.FilterValue() + s) + return m, nil + } + } + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + return m, cmd +} + +func (m selectFilterModel) View() tea.View { + if m.quitting || m.choice != "" { + return tea.NewView("") + } + return tea.NewView(m.list.View()) +} + +func (h *huhPrompter) SelectWithFilter(message string, options []string, filter func(term, option string) bool) (string, error) { + if isTesting { + return testInputAsString(), nil + } + + items := make([]list.Item, len(options)) + for i, o := range options { + items[i] = filterItem{value: o} + } + + delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(0) + // Surface the bindings we actually handle. The "/" in the first entry + // exists only so key.Binding.Enabled() returns true; the help text + // describes the behavior (typing any printable character appends to the + // filter, handled in Update below — not by bubbles/list itself). + delegate.ShortHelpFunc = func() []key.Binding { + return []key.Binding{ + key.NewBinding(key.WithKeys("/"), key.WithHelp("letters", "filter")), + key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), + key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "clear/cancel")), + } + } + + l := list.New(items, delegate, 80, 18) + l.Title = message + l.SetFilteringEnabled(true) + // Replace bubbles/list's default keymap with bindings that match what our + // Update method actually wires up; otherwise the help row advertises `/` + // filter, `q` quit, and j/k vim navigation that don't apply here. + l.KeyMap.CursorUp = key.NewBinding(key.WithKeys("up"), key.WithHelp("↑", "up")) + l.KeyMap.CursorDown = key.NewBinding(key.WithKeys("down"), key.WithHelp("↓", "down")) + l.KeyMap.Filter.Unbind() + l.KeyMap.ClearFilter.Unbind() + l.KeyMap.NextPage.Unbind() + l.KeyMap.PrevPage.Unbind() + l.KeyMap.GoToStart.Unbind() + l.KeyMap.GoToEnd.Unbind() + l.KeyMap.Quit.Unbind() + l.KeyMap.ForceQuit.Unbind() + l.KeyMap.ShowFullHelp.Unbind() + l.KeyMap.CloseFullHelp.Unbind() + l.Filter = func(term string, targets []string) []list.Rank { + if term == "" { + ranks := make([]list.Rank, len(targets)) + for i := range targets { + ranks[i] = list.Rank{Index: i} + } + return ranks + } + ranks := []list.Rank{} + for i, target := range targets { + if filter(term, target) { + ranks = append(ranks, list.Rank{Index: i}) + } + } + return ranks + } + + p := tea.NewProgram(selectFilterModel{list: l}, tea.WithInput(h.stdin), tea.WithOutput(h.stdout)) + final, err := p.Run() + if err != nil { + return "", err + } + if m, ok := final.(selectFilterModel); ok { + if m.quitting { + return "", huh.ErrUserAborted + } + return m.choice, nil + } + return "", nil +} From 324a275b0b423c7b15a0f56ceb56cd1b3f594442 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:36 -0400 Subject: [PATCH 14/18] refactor(assume): migrate profile picker to testable.SelectWithFilter QueryProfiles drops the survey.Select call and the associated SelectQuestionTemplate global-mutation hack used to render the Profile/Description column header. The header is now pre-printed via fmt.Fprintln before launching the picker, which works because bubbles/list runs inline. filterMultiToken is restored with a simpler (term, opt) signature. --- pkg/assume/assume.go | 52 +++++++------------------------------------- 1 file changed, 8 insertions(+), 44 deletions(-) diff --git a/pkg/assume/assume.go b/pkg/assume/assume.go index 09717345..c0d123d2 100644 --- a/pkg/assume/assume.go +++ b/pkg/assume/assume.go @@ -13,7 +13,6 @@ import ( "os" - "github.com/AlecAivazis/survey/v2" "github.com/alessio/shellescape" "github.com/aws/aws-sdk-go-v2/aws" awsconfig "github.com/aws/aws-sdk-go-v2/config" @@ -600,7 +599,7 @@ func EnvKeys(creds aws.Credentials, region string) []string { "AWS_REGION=" + region} } -func filterMultiToken(filterValue string, optValue string, optIndex int) bool { +func filterMultiToken(filterValue, optValue string) bool { optValue = strings.ToLower(optValue) filters := strings.Split(strings.ToLower(filterValue), " ") for _, filter := range filters { @@ -625,7 +624,6 @@ func printFlagUsage(region, service string) { } func QueryProfiles(profiles *cfaws.Profiles) (string, error) { - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) // load config to check frecency enabled cfg, err := config.Load() if err != nil { @@ -660,40 +658,7 @@ func QueryProfiles(profiles *cfaws.Profiles) (string, error) { profileNameMap[stringKey] = pn profileKeys[i] = stringKey } - var promptHeader string - // only add the description headers if there are profiles using descriptions - if hasDescriptions { - promptHeader = fmt.Sprintf(`{{- " %s\n"}}`, color.New(color.Underline, color.Bold).Sprintf("%-"+strconv.Itoa(longestProfileNameLength)+"s%s", "Profile", "Description")) - } - // This overrides the default prompt template to add a header row above the options - // this should be reset back to the original template after the call to AskOne - originalSelectTemplate := survey.SelectQuestionTemplate - survey.SelectQuestionTemplate = fmt.Sprintf(` -{{- define "option"}} - {{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}} - {{- .CurrentOpt.Value}}{{ if ne ($.GetDescription .CurrentOpt) "" }} - {{color "cyan"}}{{ $.GetDescription .CurrentOpt }}{{end}} - {{- color "reset"}} -{{end}} -{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}} -{{- color .Config.Icons.Question.Format }}{{ .Config.Icons.Question.Text }} {{color "reset"}} -{{- color "default+hb"}}{{ .Message }}{{ .FilterMessage }}{{color "reset"}} -{{- if .ShowAnswer}}{{color "cyan"}} {{.Answer}}{{color "reset"}}{{"\n"}} -{{- else}} - {{- " "}}{{- color "cyan"}}[Use arrows to move, type to filter{{- if and .Help (not .ShowHelp)}}, {{ .Config.HelpInput }} for more help{{end}}]{{color "reset"}} - {{- "\n"}} -%s{{- "\n"}} - {{- range $ix, $option := .PageEntries}} - {{- template "option" $.IterateOption $ix $option}} - {{- end}} -{{- end}}`, promptHeader) - clio.NewLine() - // Replicate the logic from original assume fn. - in := survey.Select{ - Message: "Please select the profile you would like to assume:", - Options: profileKeys, - Filter: filterMultiToken, - } if len(profileKeys) == 0 { return "", clierr.New("Granted couldn't find any AWS profiles in your config file or your credentials file", clierr.Info("You can add profiles to your AWS config by following our guide: "), @@ -701,16 +666,15 @@ func QueryProfiles(profiles *cfaws.Profiles) (string, error) { ) } - var profileName string + clio.NewLine() + if hasDescriptions { + header := color.New(color.Underline, color.Bold).Sprintf("%-"+strconv.Itoa(longestProfileNameLength)+"s%s", "Profile", "Description") + fmt.Fprintln(os.Stderr, " "+header) + } - err = testable.AskOne(&in, &profileName, withStdio) + selected, err := testable.SelectWithFilter("Please select the profile you would like to assume:", profileKeys, filterMultiToken) if err != nil { return "", err } - // Reset the template for select questions to the original - survey.SelectQuestionTemplate = originalSelectTemplate - profileName = profileNameMap[profileName] - // background task to update the frecency cache - - return profileName, nil + return profileNameMap[selected], nil } From 26983da7b3b2a0c6443109d2dd862d5e48bca9ea Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:36 -0400 Subject: [PATCH 15/18] refactor(testable): add ConfirmWithHelp and InputWithHelp Help-text variants backed by huh's Description() on the underlying field. Each shares a single private helper with its plain counterpart. --- pkg/testable/prompter.go | 41 ++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index b347d5b3..99e0b50b 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -15,11 +15,13 @@ import ( // by default because granted's stdout is shell-evaluated. type Prompter interface { Confirm(message string, defaultValue bool) (bool, error) + ConfirmWithHelp(message string, defaultValue bool, help string) (bool, error) Select(message string, options []string) (string, error) SelectWithValidator(message string, options []string, validate func(string) error) (string, error) SelectWithFilter(message string, options []string, filter func(term, option string) bool) (string, error) Input(message string, defaultValue string) (string, error) InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) + InputWithHelp(message, defaultValue, help string) (string, error) Password(message string) (string, error) } @@ -50,6 +52,11 @@ func Confirm(message string, defaultValue bool) (bool, error) { return defaultPrompter.Confirm(message, defaultValue) } +// ConfirmWithHelp is Confirm with a help string shown below the title. +func ConfirmWithHelp(message string, defaultValue bool, help string) (bool, error) { + return defaultPrompter.ConfirmWithHelp(message, defaultValue, help) +} + // Select shows a single-choice list. In testing mode it consumes one value // from the input stream configured by WithNextSurveyInputFunc. func Select(message string, options []string) (string, error) { @@ -76,6 +83,11 @@ func InputWithValidator(message, defaultValue string, validate func(string) erro return defaultPrompter.InputWithValidator(message, defaultValue, validate) } +// InputWithHelp is Input with a help string shown below the title. +func InputWithHelp(message, defaultValue, help string) (string, error) { + return defaultPrompter.InputWithHelp(message, defaultValue, help) +} + // Password shows a masked-input prompt. In testing mode it consumes one value // from the input stream configured by WithNextSurveyInputFunc. func Password(message string) (string, error) { @@ -92,14 +104,24 @@ func newHuhPrompter() *huhPrompter { } func (h *huhPrompter) Confirm(message string, defaultValue bool) (bool, error) { + return h.confirmWith(message, defaultValue, "") +} + +func (h *huhPrompter) ConfirmWithHelp(message string, defaultValue bool, help string) (bool, error) { + return h.confirmWith(message, defaultValue, help) +} + +func (h *huhPrompter) confirmWith(message string, defaultValue bool, help string) (bool, error) { if isTesting { return testInputAsBool() } ans := defaultValue + field := huh.NewConfirm().Title(message).Value(&ans) + if help != "" { + field = field.Description(help) + } err := huh.NewForm( - huh.NewGroup( - huh.NewConfirm().Title(message).Value(&ans), - ), + huh.NewGroup(field), ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() return ans, err } @@ -131,19 +153,26 @@ func (h *huhPrompter) selectWith(message string, options []string, validate func } func (h *huhPrompter) Input(message string, defaultValue string) (string, error) { - return h.inputWith(message, defaultValue, nil) + return h.inputWith(message, defaultValue, "", nil) } func (h *huhPrompter) InputWithValidator(message, defaultValue string, validate func(string) error) (string, error) { - return h.inputWith(message, defaultValue, validate) + return h.inputWith(message, defaultValue, "", validate) } -func (h *huhPrompter) inputWith(message, defaultValue string, validate func(string) error) (string, error) { +func (h *huhPrompter) InputWithHelp(message, defaultValue, help string) (string, error) { + return h.inputWith(message, defaultValue, help, nil) +} + +func (h *huhPrompter) inputWith(message, defaultValue, help string, validate func(string) error) (string, error) { if isTesting { return testInputAsString(), nil } ans := defaultValue in := huh.NewInput().Title(message).Value(&ans) + if help != "" { + in = in.Description(help) + } if validate != nil { in = in.Validate(validate) } From bc1d5b31d7c3322a8c5e0c2e6a7fb467d067cb76 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:37 -0400 Subject: [PATCH 16/18] refactor: migrate Help-using survey callers - browser/detect.go SSOBrowser: Confirm with Help -> ConfirmWithHelp - settings/requesturl/set.go: Input with Help -> InputWithHelp --- pkg/browser/detect.go | 14 +++++--------- pkg/granted/settings/requesturl/set.go | 13 +++++-------- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index e8dc8c1d..7c4d94e5 100644 --- a/pkg/browser/detect.go +++ b/pkg/browser/detect.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/AlecAivazis/survey/v2" "github.com/common-fate/clio" "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" @@ -294,14 +293,11 @@ func GrantedIntroduction() { } func SSOBrowser(grantedDefaultBrowser string) error { - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - in := &survey.Confirm{ - Message: "Use a different browser than your default browser for SSO login?", - Default: false, - Help: "For example, if you normally use a password manager in Chrome for your AWS login but Chrome is not your default browser, you would choose to use Chrome for SSO logins. You can change this later by running 'granted browser set-sso'", - } - var confirm bool - err := testable.AskOne(in, &confirm, withStdio) + confirm, err := testable.ConfirmWithHelp( + "Use a different browser than your default browser for SSO login?", + false, + "For example, if you normally use a password manager in Chrome for your AWS login but Chrome is not your default browser, you would choose to use Chrome for SSO logins. You can change this later by running 'granted browser set-sso'", + ) if err != nil { return err } diff --git a/pkg/granted/settings/requesturl/set.go b/pkg/granted/settings/requesturl/set.go index e652f812..e1674b2c 100644 --- a/pkg/granted/settings/requesturl/set.go +++ b/pkg/granted/settings/requesturl/set.go @@ -3,9 +3,7 @@ package requesturl import ( "fmt" "net/url" - "os" - "github.com/AlecAivazis/survey/v2" grantedConfig "github.com/fwdcloudsec/granted/pkg/config" "github.com/fwdcloudsec/granted/pkg/testable" "github.com/pkg/errors" @@ -24,12 +22,11 @@ var setRequestURLCommand = cli.Command{ approvalsURL = c.Args().First() if approvalsURL == "" { - in := &survey.Input{ - Message: "What is the URL of your Common Fate deployment?", - Help: "URL for your Common Fate dashboard from where users can request access \n for e.g: https://example.com", - } - withStdio := survey.WithStdio(os.Stdin, os.Stderr, os.Stderr) - err := testable.AskOne(in, &approvalsURL, withStdio) + approvalsURL, err = testable.InputWithHelp( + "What is the URL of your Common Fate deployment?", + "", + "URL for your Common Fate dashboard from where users can request access \n for e.g: https://example.com", + ) if err != nil { return err } From 6afe523c4a17d43b8e98da10b3f39a1d8afdbb4b Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:37 -0400 Subject: [PATCH 17/18] chore: drop AlecAivazis/survey/v2 dependency Removes testable.AskOne and testable.Ask along with the survey and survey/core imports from pkg/testable. go mod tidy drops AlecAivazis/survey/v2 from go.mod entirely. The library was archived by its author in 2024. --- go.mod | 3 --- go.sum | 16 ---------------- pkg/testable/testable.go | 26 -------------------------- 3 files changed, 45 deletions(-) diff --git a/go.mod b/go.mod index 8b3219d0..c6e4ba82 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25.8 require ( github.com/99designs/keyring v1.2.2 - github.com/AlecAivazis/survey/v2 v2.3.7 github.com/aws/aws-sdk-go-v2 v1.41.2 github.com/aws/aws-sdk-go-v2/config v1.27.11 github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 @@ -99,10 +98,8 @@ require ( github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b github.com/joho/godotenv v1.4.0 - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/olekukonko/tablewriter v0.0.5 github.com/russross/blackfriday/v2 v2.1.0 // indirect diff --git a/go.sum b/go.sum index 9fc84fac..55955ede 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,6 @@ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMb github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.2 h1:pZd3neh/EmUzWONb35LxQfvuY7kiSXAq3HQd97+XBn0= github.com/99designs/keyring v1.2.2/go.mod h1:wes/FrByc8j7lFOAGLGSNEg8f/PaI3cgTBqhFkHUrPk= -github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= -github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -22,8 +20,6 @@ github.com/Masterminds/semver/v3 v3.2.0 h1:3MEsd0SM6jqZojhjLWWeBY+Kcjy9i6MQAeY7Y github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= -github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0= github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= @@ -99,7 +95,6 @@ github.com/common-fate/grab v1.3.0/go.mod h1:6zH8GckZGFrOKfZzL4Y/2OTvxwFeL6cDtsz github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= @@ -129,8 +124,6 @@ github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b h1:wDUNC2eKiL35DbLvsD github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b/go.mod h1:VzxiSdG6j1pi7rwGm/xYI5RbtpBgM8sARDXlvEvxlu0= github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= -github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/huandu/xstrings v1.3.3 h1:/Gcsuc1x8JVbJ9/rlye4xZnVAbEkGauT8lbebqcQws4= github.com/huandu/xstrings v1.3.3/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA= @@ -138,8 +131,6 @@ github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= -github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -151,10 +142,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -163,9 +152,6 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= -github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ= @@ -213,7 +199,6 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= @@ -251,7 +236,6 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/pkg/testable/testable.go b/pkg/testable/testable.go index af15262e..883455cd 100644 --- a/pkg/testable/testable.go +++ b/pkg/testable/testable.go @@ -4,9 +4,6 @@ import ( "fmt" "io" "testing" - - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/core" ) var isTesting = false @@ -48,29 +45,6 @@ func NextFuncFromSlice(t *testing.T, inputs SurveyInputs, position *int) func() } } -// AskOne is a function which can be used to intercept surveys in the cli and replace the survey with input from a test input stream -// NextSurveyInput should be set to a function which returns the next string to satisfy the input -func AskOne(in survey.Prompt, out interface{}, opts ...survey.AskOpt) error { - if isTesting { - return core.WriteAnswer(out, "", nextSurveyInput()) - } - return survey.AskOne(in, out, opts...) -} - -// Ask is the multi-question counterpart to AskOne. In testing mode it consumes -// one input from the test stream per question, keyed by the question's name. -func Ask(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { - if isTesting { - for _, q := range qs { - if err := core.WriteAnswer(response, q.Name, nextSurveyInput()); err != nil { - return err - } - } - return nil - } - return survey.Ask(qs, response, opts...) -} - func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { if isTesting { validateNextOutput(format, a...) From 3d91fd6425153195c8be723c07089cebf894e869 Mon Sep 17 00:00:00 2001 From: Matt Palcic Date: Fri, 22 May 2026 14:52:37 -0400 Subject: [PATCH 18/18] feat(testable): theme huh forms and the filter picker Adds grantedTheme (based on huh.ThemeCharm) and styling helpers for the bubbles/list-based filter picker. Replaces the fuchsia/indigo accents with ANSI cyan and green, removes the outer left-border around focused fields, and switches every colour to ANSI 16 indices so the prompts pick up the user's terminal palette instead of Charm's hex codes. The filter picker's focused row indicator becomes a > cursor in cyan with green option text, matching huh.Select's SelectSelector + SelectedOption convention. Status bar stays visible to show the active filter text. --- pkg/testable/prompter.go | 8 +- pkg/testable/select_with_filter.go | 3 + pkg/testable/theme.go | 119 +++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 pkg/testable/theme.go diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go index 99e0b50b..8785779f 100644 --- a/pkg/testable/prompter.go +++ b/pkg/testable/prompter.go @@ -122,7 +122,7 @@ func (h *huhPrompter) confirmWith(message string, defaultValue bool, help string } err := huh.NewForm( huh.NewGroup(field), - ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() return ans, err } @@ -148,7 +148,7 @@ func (h *huhPrompter) selectWith(message string, options []string, validate func } err := huh.NewForm( huh.NewGroup(sel), - ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() return ans, err } @@ -178,7 +178,7 @@ func (h *huhPrompter) inputWith(message, defaultValue, help string, validate fun } err := huh.NewForm( huh.NewGroup(in), - ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() return ans, err } @@ -191,7 +191,7 @@ func (h *huhPrompter) Password(message string) (string, error) { huh.NewGroup( huh.NewInput().Title(message).EchoMode(huh.EchoModePassword).Value(&ans), ), - ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).Run() + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() return ans, err } diff --git a/pkg/testable/select_with_filter.go b/pkg/testable/select_with_filter.go index 9e4a216e..c9808825 100644 --- a/pkg/testable/select_with_filter.go +++ b/pkg/testable/select_with_filter.go @@ -115,9 +115,12 @@ func (h *huhPrompter) SelectWithFilter(message string, options []string, filter } } + applyDelegateStyles(&delegate) + l := list.New(items, delegate, 80, 18) l.Title = message l.SetFilteringEnabled(true) + applyListStyles(&l) // Replace bubbles/list's default keymap with bindings that match what our // Update method actually wires up; otherwise the help row advertises `/` // filter, `q` quit, and j/k vim navigation that don't apply here. diff --git a/pkg/testable/theme.go b/pkg/testable/theme.go new file mode 100644 index 00000000..db306112 --- /dev/null +++ b/pkg/testable/theme.go @@ -0,0 +1,119 @@ +package testable + +import ( + "charm.land/bubbles/v2/list" + "charm.land/huh/v2" + "charm.land/lipgloss/v2" +) + +// grantedTheme is ThemeCharm with the fuchsia/indigo accents replaced by +// terminal-friendly cyan and bold, keeping the form layout intact. Colors +// are specified as ANSI 16 indices ("6" cyan, "7" white) so they pick up +// whatever palette the user's terminal theme defines, rather than hard- +// coded hex values that ignore the terminal's color scheme. +func grantedTheme(isDark bool) *huh.Styles { + t := huh.ThemeCharm(isDark) + + cyan := lipgloss.Color("6") + green := lipgloss.Color("2") + black := lipgloss.Color("0") + dim := lipgloss.Color("243") + + // Drop huh's outer left-bar that wraps each focused field; the prompts + // in this project read as single-field interactions, so the "this field + // has focus" indicator is just noise. + noFrame := lipgloss.NewStyle() + t.Focused.Base = noFrame + t.Focused.Card = noFrame + t.Blurred.Base = noFrame + t.Blurred.Card = noFrame + + // Title was indigo + bold; keep bold but drop the indigo so it picks up + // the terminal's default foreground. + t.Focused.Title = lipgloss.NewStyle().Bold(true) + t.Focused.NoteTitle = lipgloss.NewStyle().Bold(true).MarginBottom(1) + t.Blurred.Title = t.Focused.Title + t.Blurred.NoteTitle = t.Focused.NoteTitle + t.Group.Title = t.Focused.Title + + // Anywhere ThemeCharm used fuchsia, use cyan. + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(cyan) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(cyan) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(cyan) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(cyan) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(black).Background(cyan) + // ThemeCharm defaults SelectedOption to a hex green that doesn't pick up + // the terminal palette; swap to ANSI green so it matches the filter + // picker's focused row. + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(green) + t.Focused.Next = t.Focused.FocusedButton + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(cyan) + + // Description tint was tied to ThemeCharm's indigo-ish palette; force + // a plain dim instead. + t.Focused.Description = lipgloss.NewStyle().Foreground(dim) + t.Blurred.Description = t.Focused.Description + t.Group.Description = t.Focused.Description + + // Mirror the changed bits onto the blurred state. + t.Blurred.SelectSelector = t.Focused.SelectSelector + t.Blurred.NextIndicator = t.Focused.NextIndicator + t.Blurred.PrevIndicator = t.Focused.PrevIndicator + t.Blurred.MultiSelectSelector = t.Focused.MultiSelectSelector + t.Blurred.FocusedButton = t.Focused.FocusedButton + t.Blurred.TextInput.Prompt = t.Focused.TextInput.Prompt + + return t +} + +// huhThemeOpt is the form option that applies grantedTheme. +var huhThemeOpt = huh.ThemeFunc(grantedTheme) + +// applyDelegateStyles mutates a bubbles/list DefaultDelegate so the focused +// row uses cyan instead of the default fuchsia. The default styles include +// padding and a left-border that aligns the selected row's text with the +// unselected rows; preserve those by chaining onto the existing styles +// rather than building new ones from scratch. +// +// Call before list.New — the list copies the delegate by value at +// construction time, so mutating the local variable afterwards has no effect. +func applyDelegateStyles(d *list.DefaultDelegate) { + cyan := lipgloss.Color("6") + // ANSI 2 picks up the terminal's "green" — same role as huh.Select's + // SelectedOption color, just palette-respecting instead of hex-locked. + green := lipgloss.Color("2") + dim := lipgloss.Color("243") + + // Replace the default per-row left-border indicator with a `>` cursor + // to match huh.Select's focus marker (and by extension survey's `❯`). + // Border is cyan (matching SelectSelector), text is green (matching + // SelectedOption) — same two-color treatment huh.Select uses. + cursorBorder := lipgloss.Border{Left: ">"} + d.Styles.SelectedTitle = d.Styles.SelectedTitle. + Border(cursorBorder, false, false, false, true). + BorderForeground(cyan). + Foreground(green). + Padding(0, 0, 0, 1) + d.Styles.SelectedDesc = d.Styles.SelectedDesc. + Border(cursorBorder, false, false, false, true). + BorderForeground(cyan). + Foreground(green). + Padding(0, 0, 0, 1) + d.Styles.DimmedTitle = d.Styles.DimmedTitle.Foreground(dim) + d.Styles.DimmedDesc = d.Styles.DimmedDesc.Foreground(dim) + d.Styles.FilterMatch = lipgloss.NewStyle().Underline(true) +} + +// applyListStyles mutates a bubbles/list Model so its chrome matches the +// huh.Select aesthetic: bold-neutral title, flush-left bars. The status +// bar stays visible — it's the only feedback channel for showing what +// the user has typed into the filter (rendered as `"" N items`). +// The defaults indent every bar 2 chars and add a blank line between +// sections, which makes the picker look more padded than the other prompts. +func applyListStyles(l *list.Model) { + l.Styles.Title = lipgloss.NewStyle().Bold(true) + l.Styles.TitleBar = lipgloss.NewStyle() + l.Styles.StatusBar = lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + l.Styles.HelpStyle = lipgloss.NewStyle().PaddingTop(1) + l.Styles.PaginationStyle = lipgloss.NewStyle() +}