diff --git a/go.mod b/go.mod index 3c999865..c6e4ba82 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,9 @@ module github.com/fwdcloudsec/granted -go 1.25.7 +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 @@ -17,10 +16,14 @@ 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 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 +31,44 @@ require ( ) require ( + 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/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 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 @@ -77,17 +98,15 @@ 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 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..55955ede 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,29 @@ +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= 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= +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= 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= +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 +52,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 +95,16 @@ 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= @@ -82,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= @@ -91,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= @@ -100,29 +138,32 @@ 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/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +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.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= 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/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/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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= 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= @@ -142,6 +183,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= @@ -156,12 +199,13 @@ 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= 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 +224,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= @@ -190,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= @@ -200,8 +245,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= diff --git a/pkg/alias/alias.go b/pkg/alias/alias.go index ce995816..2964c7e1 100644 --- a/pkg/alias/alias.go +++ b/pkg/alias/alias.go @@ -14,10 +14,10 @@ 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" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/fatih/color" ) @@ -197,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 = survey.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/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 } diff --git a/pkg/browser/detect.go b/pkg/browser/detect.go index 28bfeda0..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" @@ -47,14 +46,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 } @@ -194,7 +190,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 @@ -204,12 +199,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 } @@ -223,7 +213,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 @@ -250,9 +239,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 } @@ -304,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 } @@ -342,14 +328,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 +356,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 } @@ -401,7 +376,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 @@ -416,9 +390,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/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 } diff --git a/pkg/granted/credentials.go b/pkg/granted/credentials.go index 1d41380c..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 } @@ -244,13 +241,11 @@ 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 } - 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 } @@ -274,8 +269,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 +351,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 } @@ -371,12 +364,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 = survey.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 } @@ -434,8 +422,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 +524,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/gitregistry/config_yaml.go b/pkg/granted/registry/gitregistry/config_yaml.go index 1af80361..939f57c2 100644 --- a/pkg/granted/registry/gitregistry/config_yaml.go +++ b/pkg/granted/registry/gitregistry/config_yaml.go @@ -6,9 +6,9 @@ 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" "gopkg.in/yaml.v3" ) @@ -63,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) @@ -113,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 = survey.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 @@ -167,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) 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/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/setup.go b/pkg/granted/registry/setup.go index bdb0b008..2bc0976c 100644 --- a/pkg/granted/registry/setup.go +++ b/pkg/granted/registry/setup.go @@ -4,10 +4,10 @@ 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" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" ) @@ -39,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 = survey.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/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/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/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/requesturl/set.go b/pkg/granted/settings/requesturl/set.go index 12941370..e1674b2c 100644 --- a/pkg/granted/settings/requesturl/set.go +++ b/pkg/granted/settings/requesturl/set.go @@ -3,10 +3,9 @@ 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" "github.com/urfave/cli/v2" ) @@ -23,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 := survey.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 } diff --git a/pkg/granted/settings/set.go b/pkg/granted/settings/set.go index ebe85955..4dcd4d42 100644 --- a/pkg/granted/settings/set.go +++ b/pkg/granted/settings/set.go @@ -5,10 +5,10 @@ import ( "reflect" "strconv" - "github.com/AlecAivazis/survey/v2" "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" ) @@ -45,11 +45,7 @@ var SetConfigCommand = cli.Command{ var selectedFieldName = c.String("setting") if selectedFieldName == "" { - p := &survey.Select{ - Message: "Select the configuration to change", - Options: fields, - } - err = survey.AskOne(p, &selectedFieldName) + selectedFieldName, err = testable.Select("Select the configuration to change", fields) if err != nil { return err } @@ -62,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 = survey.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) @@ -86,11 +79,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 = survey.AskOne(prompt, &str) + str, err = testable.Input(fmt.Sprintf("Enter new value for %s:", selectedFieldName), selectedField.Value().(string)) if err != nil { return err } @@ -100,14 +89,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 = survey.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 } 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 } diff --git a/pkg/granted/uninstall.go b/pkg/granted/uninstall.go index cc80a90f..d7c383f8 100644 --- a/pkg/granted/uninstall.go +++ b/pkg/granted/uninstall.go @@ -3,10 +3,10 @@ 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" + "github.com/fwdcloudsec/granted/pkg/testable" "github.com/urfave/cli/v2" ) @@ -14,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 := survey.AskOne(in, &confirm, withStdio) + confirm, err := testable.Confirm("Are you sure you want to remove your Granted config?", true) if err != nil { return err } diff --git a/pkg/testable/prompter.go b/pkg/testable/prompter.go new file mode 100644 index 00000000..8785779f --- /dev/null +++ b/pkg/testable/prompter.go @@ -0,0 +1,220 @@ +package testable + +import ( + "errors" + "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) + 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) +} + +// 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 +) + +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) +} + +// 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) { + 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) +} + +// 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) { + return defaultPrompter.Password(message) +} + +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) { + 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(field), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() + return ans, err +} + +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(sel), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).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) 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) + } + err := huh.NewForm( + huh.NewGroup(in), + ).WithInput(h.stdin).WithOutput(h.stdout).WithKeyMap(huhKeyMap).WithTheme(huhThemeOpt).Run() + 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).WithTheme(huhThemeOpt).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) { + 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) + } +} diff --git a/pkg/testable/prompter_test.go b/pkg/testable/prompter_test.go new file mode 100644 index 00000000..98782205 --- /dev/null +++ b/pkg/testable/prompter_test.go @@ -0,0 +1,75 @@ +package testable + +import "testing" + +// setNextInput installs a scripted test input for the duration of the test and +// restores the previous hook afterwards, so tests do not leak global state. +func setNextInput(t *testing.T, v StringOrBool) { + t.Helper() + orig := nextSurveyInput + t.Cleanup(func() { nextSurveyInput = orig }) + nextSurveyInput = func() StringOrBool { return v } +} + +func TestRequired(t *testing.T) { + if err := Required(""); err == nil { + t.Error("Required(\"\") = nil, want error rejecting empty input") + } + if err := Required("x"); err != nil { + t.Errorf("Required(\"x\") = %v, want nil", err) + } +} + +func TestTestInputAsBool(t *testing.T) { + tests := []struct { + name string + input StringOrBool + want bool + wantErr bool + }{ + {name: "native bool true", input: true, want: true}, + {name: "native bool false", input: false, want: false}, + {name: "parseable string true", input: "true", want: true}, + {name: "parseable string false", input: "false", want: false}, + {name: "unparseable string", input: "nope", wantErr: true}, + {name: "unexpected type", input: 5, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setNextInput(t, tt.input) + got, err := testInputAsBool() + if tt.wantErr { + if err == nil { + t.Fatalf("testInputAsBool(%v) error = nil, want error", tt.input) + } + return + } + if err != nil { + t.Fatalf("testInputAsBool(%v) unexpected error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("testInputAsBool(%v) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestTestInputAsString(t *testing.T) { + tests := []struct { + name string + input StringOrBool + want string + }{ + {name: "string passthrough", input: "hello", want: "hello"}, + {name: "nil becomes empty", input: nil, want: ""}, + {name: "non-string is formatted", input: 7, want: "7"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + setNextInput(t, tt.input) + if got := testInputAsString(); got != tt.want { + t.Errorf("testInputAsString(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} diff --git a/pkg/testable/select_with_filter.go b/pkg/testable/select_with_filter.go new file mode 100644 index 00000000..9cab60ab --- /dev/null +++ b/pkg/testable/select_with_filter.go @@ -0,0 +1,184 @@ +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 + } + + m := newSelectFilterModel(message, options, filter) + p := tea.NewProgram(m, 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 +} + +// rankFilter ranks targets against term using match. An empty term matches +// everything, preserving order; otherwise only targets that match accepts are +// returned. This is the pure logic behind the bubbles/list Filter hook, split +// out so it can be unit-tested without a running program. +func rankFilter(term string, targets []string, match func(term, option string) bool) []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 match(term, target) { + ranks = append(ranks, list.Rank{Index: i}) + } + } + return ranks +} + +// newSelectFilterModel builds the bubbles/list-backed model used by +// SelectWithFilter. It is separated from SelectWithFilter so tests can drive +// the same model production uses without opening a terminal. +func newSelectFilterModel(message string, options []string, filter func(term, option string) bool) selectFilterModel { + 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")), + } + } + + 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. + 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 { + return rankFilter(term, targets, filter) + } + + return selectFilterModel{list: l} +} diff --git a/pkg/testable/select_with_filter_test.go b/pkg/testable/select_with_filter_test.go new file mode 100644 index 00000000..45b0217e --- /dev/null +++ b/pkg/testable/select_with_filter_test.go @@ -0,0 +1,119 @@ +package testable + +import ( + "strings" + "testing" + + tea "charm.land/bubbletea/v2" +) + +func TestRankFilter(t *testing.T) { + targets := []string{"alpha", "beta", "gamma"} + // The filter receives (term, option); this matcher accepts options that + // contain the term, matching how callers use SelectWithFilter. + contains := func(term, option string) bool { return strings.Contains(option, term) } + + t.Run("empty term returns every target in order", func(t *testing.T) { + got := rankFilter("", targets, contains) + if len(got) != len(targets) { + t.Fatalf("got %d ranks, want %d", len(got), len(targets)) + } + for i, r := range got { + if r.Index != i { + t.Errorf("rank[%d].Index = %d, want %d", i, r.Index, i) + } + } + }) + + t.Run("non-empty term keeps only matches, by original index", func(t *testing.T) { + got := rankFilter("bet", targets, contains) + if len(got) != 1 || got[0].Index != 1 { + t.Fatalf("term \"bet\": got %+v, want a single rank with Index=1", got) + } + }) + + t.Run("no matches returns an empty result", func(t *testing.T) { + if got := rankFilter("zzz", targets, contains); len(got) != 0 { + t.Errorf("term \"zzz\": got %d ranks, want 0", len(got)) + } + }) +} + +func TestSelectFilterModelUpdate(t *testing.T) { + options := []string{"alpha", "beta", "gamma"} + contains := func(term, option string) bool { return strings.Contains(option, term) } + newModel := func() selectFilterModel { return newSelectFilterModel("pick", options, contains) } + + press := func(m selectFilterModel, k tea.Key) selectFilterModel { + next, _ := m.Update(tea.KeyPressMsg(k)) + return next.(selectFilterModel) + } + typed := func(m selectFilterModel, s string) selectFilterModel { + for _, r := range s { + m = press(m, tea.Key{Code: r, Text: string(r)}) + } + return m + } + + t.Run("enter selects the highlighted option", func(t *testing.T) { + m := press(newModel(), tea.Key{Code: tea.KeyEnter}) + if m.quitting { + t.Error("enter set quitting = true, want false") + } + if m.choice != "alpha" { + t.Errorf("choice = %q, want %q (first option is highlighted by default)", m.choice, "alpha") + } + }) + + t.Run("ctrl+c cancels without a choice", func(t *testing.T) { + m := press(newModel(), tea.Key{Code: 'c', Mod: tea.ModCtrl}) + if !m.quitting { + t.Error("ctrl+c did not set quitting = true") + } + if m.choice != "" { + t.Errorf("choice = %q, want empty on cancel", m.choice) + } + }) + + t.Run("esc with no active filter cancels", func(t *testing.T) { + m := press(newModel(), tea.Key{Code: tea.KeyEscape}) + if !m.quitting { + t.Error("esc with no filter did not set quitting = true") + } + }) + + // This is the behavior the survey->huh migration set out to add: ESC first + // clears an active filter and only cancels the picker on a second press. + t.Run("esc with an active filter clears it instead of cancelling", func(t *testing.T) { + m := typed(newModel(), "be") + if got := m.list.FilterValue(); got != "be" { + t.Fatalf("precondition: FilterValue = %q, want %q", got, "be") + } + m = press(m, tea.Key{Code: tea.KeyEscape}) + if m.quitting { + t.Error("esc cleared the filter but also set quitting = true, want false") + } + if got := m.list.FilterValue(); got != "" { + t.Errorf("FilterValue after clearing esc = %q, want empty", got) + } + }) + + t.Run("printable characters build up the filter", func(t *testing.T) { + m := typed(newModel(), "al") + if got := m.list.FilterValue(); got != "al" { + t.Errorf("FilterValue = %q, want %q", got, "al") + } + }) + + t.Run("backspace shortens the filter, then clears it", func(t *testing.T) { + m := typed(newModel(), "al") + m = press(m, tea.Key{Code: tea.KeyBackspace}) + if got := m.list.FilterValue(); got != "a" { + t.Errorf("FilterValue after one backspace = %q, want %q", got, "a") + } + m = press(m, tea.Key{Code: tea.KeyBackspace}) + if got := m.list.FilterValue(); got != "" { + t.Errorf("FilterValue after final backspace = %q, want empty", got) + } + }) +} diff --git a/pkg/testable/testable.go b/pkg/testable/testable.go index a0426ed9..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,15 +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...) -} - func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { if isTesting { validateNextOutput(format, a...) 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() +}