diff --git a/CHANGELOG.md b/CHANGELOG.md index 7007c56..ef8309d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,26 @@ Notes for upgrading... ### Fixed +[0.15.0] - 2025.12.30 +--------------------- + +Version 0.15.0 migrates interactive prompts from the archived survey library to Charm (bubbletea/huh). This release also adds a new `util validate-saml` command to help troubleshoot SAML authentication configurations. + +### Added + +- New `util validate-saml` command to help verify SAML configurations [kionsoftware/kion-cli/pull/104] + +### Changed + +- Replaced survey library with Charm (bubbletea/huh) as survey has been archived [kionsoftware/kion-cli/pull/106] +- Improved SAML authentication error handling with more descriptive messages for misconfigurations [kionsoftware/kion-cli/pull/104] + +### Fixed + +- Patched `github.com/dvsekhvalnov/jose2go` to version 1.8.0 to address CVE-2025-63811 [kionsoftware/kion-cli/pull/106] + [0.14.0] - 2025.09.29 -------------------------- +--------------------- Version 0.14.0 integrates favorites between Kion and the Kion CLI (requires Kion version 3.13.5, 3.14.1 or greater). This allows Kion to be the new source of truth for your configured favorites and simplifies maintenance of the Kion CLI configuration file. If you already have favorites defined in your Kion CLI configuration file it is recommended you run `kion util push-favorites` once to sync them with your instance of Kion. diff --git a/VERSION.md b/VERSION.md index 4a29f93..86dd09a 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -v0.14.0 +v0.15.0 diff --git a/go.mod b/go.mod index 21209dd..0dda836 100644 --- a/go.mod +++ b/go.mod @@ -4,44 +4,54 @@ go 1.24.4 require ( github.com/99designs/keyring v1.2.2 - github.com/AlecAivazis/survey/v2 v2.3.6 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 github.com/fatih/color v1.15.0 github.com/hashicorp/go-version v1.6.0 github.com/russellhaering/gosaml2 v0.9.1 github.com/russellhaering/goxmldsig v1.4.0 github.com/urfave/cli/v2 v2.25.1 + golang.org/x/term v0.7.0 gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/beevik/etree v1.1.0 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.8.0 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/danieljoos/wincred v1.1.2 // indirect - github.com/dvsekhvalnov/jose2go v1.6.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/dvsekhvalnov/jose2go v1.8.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/jonboulle/clockwork v0.3.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/term v0.7.0 // indirect - golang.org/x/text v0.9.0 // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.23.0 // indirect ) diff --git a/go.sum b/go.sum index 73502be..621ae20 100644 --- a/go.sum +++ b/go.sum @@ -2,36 +2,62 @@ 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.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw= -github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI= -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/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/beevik/etree v1.1.0 h1:T0xke/WvNtMoCqgzPhkX2r4rjY3GDZFi+FjpRZY2Jbs= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= -github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= github.com/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= -github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= +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/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= @@ -40,13 +66,9 @@ github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8 github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek= github.com/hashicorp/go-version v1.6.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/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jonboulle/clockwork v0.3.0 h1:9BSCMi8C+0qdApAp4auwX0RkLGUjs956h0EkuQymUhg= github.com/jonboulle/clockwork v0.3.0/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= -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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -59,22 +81,23 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= -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.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= -github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.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/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/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/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -106,23 +129,20 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM 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= -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/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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= -golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/lib/commands/authentication.go b/lib/commands/authentication.go index 8bade4f..fb9690a 100644 --- a/lib/commands/authentication.go +++ b/lib/commands/authentication.go @@ -28,7 +28,7 @@ func (c *Cmd) authUNPW(cCtx *cli.Context) error { } iNames, iMap := helper.MapIDMSs(idmss) if len(iNames) > 1 { - idms, err := helper.PromptSelect("Select Login IDMS:", iNames) + idms, err := helper.PromptSelect("Select Login IDMS:", "Select your IDMS from the list below.", iNames) if err != nil { return err } @@ -267,7 +267,7 @@ func (c *Cmd) setAuthToken(cCtx *cli.Context) error { "Password", "SAML", } - authMethod, err := helper.PromptSelect("How would you like to authenticate", methods) + authMethod, err := helper.PromptSelect("How would you like to authenticate?", "Choose your preferred authentication method.", methods) if err != nil { return err } diff --git a/lib/commands/favorite.go b/lib/commands/favorite.go index 21caadf..f06dd1c 100644 --- a/lib/commands/favorite.go +++ b/lib/commands/favorite.go @@ -93,7 +93,7 @@ func (c *Cmd) Favorites(cCtx *cli.Context) error { if fMap[cCtx.Args().First()] != (structs.Favorite{}) { fav = cCtx.Args().First() } else { - fav, err = helper.PromptSelect("Choose a Favorite:", fNames) + fav, err = helper.PromptSelect("Choose a Favorite:", "Select your favorite from the list below.", fNames) if err != nil { return err } diff --git a/lib/commands/utility.go b/lib/commands/utility.go index 174a063..068b6ba 100644 --- a/lib/commands/utility.go +++ b/lib/commands/utility.go @@ -3,7 +3,6 @@ package commands import ( "errors" "fmt" - "strings" "github.com/fatih/color" "github.com/kionsoftware/kion-cli/lib/helper" @@ -67,73 +66,6 @@ func (c *Cmd) createUpstreamFavorite(favorites []structs.Favorite) error { // // //////////////////////////////////////////////////////////////////////////////// -// ValidateSAML validates SAML configuration and connectivity. -func (c *Cmd) ValidateSAML(cCtx *cli.Context) error { - ctx := newValidationContext() - - // Header - fmt.Println() - fmt.Println(ctx.styles.renderMainHeader("SAML Configuration Validation")) - fmt.Println(ctx.styles.renderSeparator()) - fmt.Println() - - // Check basic configuration - if err := c.checkBasicConfig(ctx); err != nil { - return err - } - - // Check port availability - c.checkPortAvailability(ctx) - - // Check Kion connectivity - kionAccessible := c.checkKionConnectivity(ctx) - - // Load and validate metadata - metadata, err := c.loadMetadata(ctx) - if err == nil { - // Validate metadata structure - if c.validateMetadataStructure(ctx, metadata) { - // Validate certificates - c.validateCertificates(ctx, metadata) - - // Check SSO URL reachability - c.checkSSOURLReachability(ctx, metadata) - } - } - fmt.Println() - - // Check CSRF endpoint if Kion is accessible - if kionAccessible { - c.checkCSRFEndpoint(ctx) - } - - // Summary - fmt.Println(ctx.styles.renderSeparator()) - if ctx.allPassed { - var summary strings.Builder - summary.WriteString("✓ All validation checks passed!\n\n") - summary.WriteString("Your SAML configuration appears to be correct.\n") - summary.WriteString("Try running SAML authentication to complete the flow.") - - successBox := ctx.styles.summaryBox.BorderForeground(ctx.styles.checkMark.GetForeground()) - fmt.Println(successBox.Render(summary.String())) - - // Print metadata details after success message - if metadata != nil { - c.printMetadataDetails(ctx, metadata) - } - return nil - } - - var summary strings.Builder - summary.WriteString("✗ Some validation checks failed.\n\n") - summary.WriteString("Please review the errors above and fix the configuration.") - - failBox := ctx.styles.summaryBox.BorderForeground(ctx.styles.xMark.GetForeground()) - fmt.Println(failBox.Render(summary.String())) - return fmt.Errorf("SAML validation failed") -} - // FlushCache clears the Kion CLI cache. func (c *Cmd) FlushCache(cCtx *cli.Context) error { return c.cache.FlushCache() @@ -199,14 +131,15 @@ func (c *Cmd) PushFavorites(cCtx *cli.Context) error { prompt += "\nDo you want to continue?" // Confirm the push. - selection, err := helper.PromptSelect(prompt, []string{"no", "yes"}) + selection, err := helper.PromptSelect(prompt, "", []string{"no", "yes"}) if selection == "no" || err != nil { fmt.Println("\nAborting push of favorites.") return err } if len(favorites.ConflictsLocal) > 0 { confirm, err := helper.PromptSelect( - "\nConflicting favorites in Kion will be overwritten, are you sure you want to continue?", + "Conflicting favorites in Kion will be overwritten, are you sure you want to continue?", + "", []string{"no", "yes"}, ) if confirm == "no" || err != nil { @@ -245,8 +178,9 @@ func (c *Cmd) PushFavorites(cCtx *cli.Context) error { } } +// DeleteLocalFavorites prompts for confirmation and deletes local favorites. func (c *Cmd) DeleteLocalFavorites(cCtx *cli.Context) error { - confirmDelete, err := helper.PromptSelect("\nDo you want to delete the local favorites?", []string{"no", "yes"}) + confirmDelete, err := helper.PromptSelect("Do you want to delete the local favorites?", "", []string{"no", "yes"}) if err != nil { color.Red("Error prompting for deletion confirmation: %v\n", err) return err diff --git a/lib/commands/validators.go b/lib/commands/validators.go index 9dc0fc8..22996a4 100644 --- a/lib/commands/validators.go +++ b/lib/commands/validators.go @@ -8,202 +8,29 @@ import ( "net" "net/http" "net/url" - "os" "strings" "time" "github.com/kionsoftware/kion-cli/lib/helper" "github.com/kionsoftware/kion-cli/lib/kion" "github.com/kionsoftware/kion-cli/lib/structs" + "github.com/kionsoftware/kion-cli/lib/styles" - "github.com/charmbracelet/lipgloss" samlTypes "github.com/russellhaering/gosaml2/types" "github.com/urfave/cli/v2" - "golang.org/x/term" ) // validationContext holds context for SAML validation. type validationContext struct { - styles *validationStyles + styles *styles.OutputStyles httpClient *http.Client allPassed bool } -// validationStyles holds all the Lipgloss styles for SAML validation output -type validationStyles struct { - // Status indicators - checkMark lipgloss.Style - xMark lipgloss.Style - - // Text styles - checkLabel lipgloss.Style - errorText lipgloss.Style - warningText lipgloss.Style - infoText lipgloss.Style - detailText lipgloss.Style - - // Headers and sections - mainHeader lipgloss.Style - sectionHeader lipgloss.Style - separator lipgloss.Style - - // Boxes and containers - detailsBox lipgloss.Style - summaryBox lipgloss.Style - - // Layout dimensions - terminalWidth int - checkLabelWidth int -} - -//////////////////////////////////////////////////////////////////////////////// -// // -// Validation Helpers // -// // -//////////////////////////////////////////////////////////////////////////////// - -// newValidationStyles creates a new set of validation styles -func newValidationStyles() *validationStyles { - // Detect terminal width - termWidth := 80 // Default fallback - if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { - termWidth = width - } - - // Use terminal width - 2 for margins, but cap at 80 and minimum 50 - effectiveWidth := max(min(termWidth-2, 80), 50) - - // Calculate check label width: total width - space (1) - checkmark (1) - checkLabelWidth := effectiveWidth - 2 - - // Box width should match the separator width (checkLabelWidth + space + checkmark) - // The Width() in lipgloss refers to content width, excluding borders and padding - // Total width = content width + left border (1) + right border (1) + left padding (1) + right padding (1) - // So content width = total desired width - 4 - // But we want the outer box edge to align with separator, so we need to match separator width - boxWidth := checkLabelWidth + 4 - - return &validationStyles{ - checkMark: lipgloss.NewStyle(). - Foreground(lipgloss.Color("10")). // Green - Bold(true), - - xMark: lipgloss.NewStyle(). - Foreground(lipgloss.Color("9")). // Red - Bold(true), - - checkLabel: lipgloss.NewStyle(). - Width(checkLabelWidth). - Align(lipgloss.Left), - - errorText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("9")). // Red - PaddingLeft(2), - - warningText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("11")). // Yellow - PaddingLeft(2), - - infoText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("12")). // Blue - PaddingLeft(2), - - detailText: lipgloss.NewStyle(). - Foreground(lipgloss.Color("245")). // Gray - PaddingLeft(2), - - mainHeader: lipgloss.NewStyle(). - Foreground(lipgloss.Color("14")). // Cyan - Bold(true). - Padding(0, 1), - - sectionHeader: lipgloss.NewStyle(). - Foreground(lipgloss.Color("12")). // Blue - Bold(true). - PaddingLeft(0). - MarginTop(1), - - separator: lipgloss.NewStyle(). - Foreground(lipgloss.Color("240")). // Dark gray - Bold(false), - - detailsBox: lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("12")). // Blue - PaddingLeft(1). - PaddingRight(1). - Width(boxWidth - 4). // Subtract borders (2) and padding (2) - MarginTop(1). - MarginBottom(1), - - summaryBox: lipgloss.NewStyle(). - Border(lipgloss.DoubleBorder()). - BorderForeground(lipgloss.Color("10")). // Green - PaddingLeft(1). - PaddingRight(1). - Width(boxWidth - 4). // Subtract borders (2) and padding (2) - MarginTop(1). - MarginBottom(1), - - terminalWidth: termWidth, - checkLabelWidth: checkLabelWidth, - } -} - -// renderCheck renders a check result with label and status -func (s *validationStyles) renderCheck(label string, passed bool) string { - status := s.checkMark.Render("✓") - if !passed { - status = s.xMark.Render("✗") - } - return s.checkLabel.Render(label) + " " + status -} - -// renderDetail renders a detail line (indented) -func (s *validationStyles) renderDetail(text string) string { - return s.detailText.Render(text) -} - -// renderError renders an error message -func (s *validationStyles) renderError(text string) string { - return s.errorText.Render("Error: " + text) -} - -// renderWarning renders a warning message -func (s *validationStyles) renderWarning(text string) string { - return s.warningText.Render("Warning: " + text) -} - -// renderFix renders a fix suggestion -func (s *validationStyles) renderFix(text string) string { - return s.warningText.Render("Fix: " + text) -} - -// renderNote renders a note -func (s *validationStyles) renderNote(text string) string { - return s.infoText.Render("Note: " + text) -} - -// renderSeparator renders a separator line that aligns with check labels -func (s *validationStyles) renderSeparator() string { - // Separator width = checkLabelWidth + 1 space + 1 checkmark - width := s.checkLabelWidth + 2 - line := "" - for range width { - line += "─" - } - return s.separator.Render(line) -} - -// renderMainHeader renders the main header -func (s *validationStyles) renderMainHeader(text string) string { - return s.mainHeader.Render(text) -} - // newValidationContext creates a new validation context. func newValidationContext() *validationContext { return &validationContext{ - styles: newValidationStyles(), + styles: styles.NewOutputStyles(), httpClient: &http.Client{ Timeout: 10 * time.Second, }, @@ -211,41 +38,47 @@ func newValidationContext() *validationContext { } } +//////////////////////////////////////////////////////////////////////////////// +// // +// Validation Helpers // +// // +//////////////////////////////////////////////////////////////////////////////// + // checkBasicConfig validates basic SAML configuration parameters func (c *Cmd) checkBasicConfig(ctx *validationContext) error { // Check 1: Kion URL is configured if c.config.Kion.URL == "" { - fmt.Println(ctx.styles.renderCheck("Kion URL configured", false)) - fmt.Println(ctx.styles.renderError("Kion URL is not configured")) - fmt.Println(ctx.styles.renderFix("Set 'url' in ~/.kion.yml or use --url flag")) + fmt.Println(ctx.styles.RenderCheck("Kion URL configured", false)) + fmt.Println(ctx.styles.RenderError("Kion URL is not configured")) + fmt.Println(ctx.styles.RenderFix("Set 'url' in ~/.kion.yml or use --url flag")) ctx.allPassed = false } else { - fmt.Println(ctx.styles.renderCheck("Kion URL configured", true)) - fmt.Println(ctx.styles.renderDetail("URL: " + c.config.Kion.URL)) + fmt.Println(ctx.styles.RenderCheck("Kion URL configured", true)) + fmt.Println(ctx.styles.RenderDetail("URL: " + c.config.Kion.URL)) } fmt.Println() // Check 2: SAML Metadata File/URL is configured if c.config.Kion.SamlMetadataFile == "" { - fmt.Println(ctx.styles.renderCheck("SAML Metadata configured", false)) - fmt.Println(ctx.styles.renderError("SAML Metadata File/URL is not configured")) - fmt.Println(ctx.styles.renderFix("Set 'saml_metadata_file' in ~/.kion.yml or use --saml-metadata-file flag")) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata configured", false)) + fmt.Println(ctx.styles.RenderError("SAML Metadata File/URL is not configured")) + fmt.Println(ctx.styles.RenderFix("Set 'saml_metadata_file' in ~/.kion.yml or use --saml-metadata-file flag")) ctx.allPassed = false } else { - fmt.Println(ctx.styles.renderCheck("SAML Metadata configured", true)) - fmt.Println(ctx.styles.renderDetail("Source: " + c.config.Kion.SamlMetadataFile)) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata configured", true)) + fmt.Println(ctx.styles.RenderDetail("Source: " + c.config.Kion.SamlMetadataFile)) } fmt.Println() // Check 3: SAML Service Provider Issuer is configured if c.config.Kion.SamlIssuer == "" { - fmt.Println(ctx.styles.renderCheck("SAML SP Issuer configured", false)) - fmt.Println(ctx.styles.renderError("SAML Service Provider Issuer is not configured")) - fmt.Println(ctx.styles.renderFix("Set 'saml_sp_issuer' in ~/.kion.yml or use --saml-sp-issuer flag")) + fmt.Println(ctx.styles.RenderCheck("SAML SP Issuer configured", false)) + fmt.Println(ctx.styles.RenderError("SAML Service Provider Issuer is not configured")) + fmt.Println(ctx.styles.RenderFix("Set 'saml_sp_issuer' in ~/.kion.yml or use --saml-sp-issuer flag")) ctx.allPassed = false } else { - fmt.Println(ctx.styles.renderCheck("SAML SP Issuer configured", true)) - fmt.Println(ctx.styles.renderDetail("Issuer: " + c.config.Kion.SamlIssuer)) + fmt.Println(ctx.styles.RenderCheck("SAML SP Issuer configured", true)) + fmt.Println(ctx.styles.RenderDetail("Issuer: " + c.config.Kion.SamlIssuer)) } fmt.Println() @@ -256,17 +89,17 @@ func (c *Cmd) checkBasicConfig(ctx *validationContext) error { isValidURN := strings.HasPrefix(c.config.Kion.SamlIssuer, "urn:") if isValidURL || isValidURN { - fmt.Println(ctx.styles.renderCheck("SAML SP Issuer format is valid", true)) + fmt.Println(ctx.styles.RenderCheck("SAML SP Issuer format is valid", true)) if isValidURL { - fmt.Println(ctx.styles.renderDetail("Format: URL")) + fmt.Println(ctx.styles.RenderDetail("Format: URL")) } else { - fmt.Println(ctx.styles.renderDetail("Format: URN")) + fmt.Println(ctx.styles.RenderDetail("Format: URN")) } } else { - fmt.Println(ctx.styles.renderCheck("SAML SP Issuer format is valid", false)) - fmt.Println(ctx.styles.renderWarning("SP Issuer should be a valid URL or URN")) - fmt.Println(ctx.styles.renderFix("Common formats: https://your-kion-url.com or urn:your:issuer:id")) - fmt.Println(ctx.styles.renderNote("This may still work, but could cause issues with some IDPs")) + fmt.Println(ctx.styles.RenderCheck("SAML SP Issuer format is valid", false)) + fmt.Println(ctx.styles.RenderWarning("SP Issuer should be a valid URL or URN")) + fmt.Println(ctx.styles.RenderFix("Common formats: https://your-kion-url.com or urn:your:issuer:id")) + fmt.Println(ctx.styles.RenderNote("This may still work, but could cause issues with some IDPs")) } fmt.Println() } @@ -274,7 +107,7 @@ func (c *Cmd) checkBasicConfig(ctx *validationContext) error { // If basic config is missing, stop here if !ctx.allPassed { fmt.Println() - fmt.Println(ctx.styles.renderWarning("Please configure the missing parameters before continuing.")) + fmt.Println(ctx.styles.RenderWarning("Please configure the missing parameters before continuing.")) return fmt.Errorf("SAML configuration is incomplete") } @@ -285,15 +118,15 @@ func (c *Cmd) checkBasicConfig(ctx *validationContext) error { func (c *Cmd) checkPortAvailability(ctx *validationContext) { listener, err := net.Listen("tcp", ":8400") if err != nil { - fmt.Println(ctx.styles.renderCheck("Port 8400 is available for callback", false)) - fmt.Println(ctx.styles.renderError("Port 8400 is already in use")) - fmt.Println(ctx.styles.renderNote("The SAML callback server needs port 8400 to be available")) - fmt.Println(ctx.styles.renderFix("Please stop any process using this port and try again")) + fmt.Println(ctx.styles.RenderCheck("Port 8400 is available for callback", false)) + fmt.Println(ctx.styles.RenderError("Port 8400 is already in use")) + fmt.Println(ctx.styles.RenderNote("The SAML callback server needs port 8400 to be available")) + fmt.Println(ctx.styles.RenderFix("Please stop any process using this port and try again")) ctx.allPassed = false } else { listener.Close() - fmt.Println(ctx.styles.renderCheck("Port 8400 is available for callback", true)) - fmt.Println(ctx.styles.renderDetail("Port is available for SAML callback")) + fmt.Println(ctx.styles.RenderCheck("Port 8400 is available for callback", true)) + fmt.Println(ctx.styles.RenderDetail("Port is available for SAML callback")) } fmt.Println() } @@ -303,18 +136,18 @@ func (c *Cmd) checkKionConnectivity(ctx *validationContext) bool { kionAccessible := false resp, err := ctx.httpClient.Get(c.config.Kion.URL) if err != nil { - fmt.Println(ctx.styles.renderCheck("Kion server is accessible", false)) - fmt.Println(ctx.styles.renderError(err.Error())) + fmt.Println(ctx.styles.RenderCheck("Kion server is accessible", false)) + fmt.Println(ctx.styles.RenderError(err.Error())) ctx.allPassed = false } else { resp.Body.Close() if resp.StatusCode < 500 { - fmt.Println(ctx.styles.renderCheck("Kion server is accessible", true)) - fmt.Println(ctx.styles.renderDetail(fmt.Sprintf("Status: HTTP %d", resp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("Kion server is accessible", true)) + fmt.Println(ctx.styles.RenderDetail(fmt.Sprintf("Status: HTTP %d", resp.StatusCode))) kionAccessible = true } else { - fmt.Println(ctx.styles.renderCheck("Kion server is accessible", false)) - fmt.Println(ctx.styles.renderError(fmt.Sprintf("HTTP %d - Server error", resp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("Kion server is accessible", false)) + fmt.Println(ctx.styles.RenderError(fmt.Sprintf("HTTP %d - Server error", resp.StatusCode))) ctx.allPassed = false } } @@ -327,18 +160,18 @@ func (c *Cmd) checkKionConnectivity(ctx *validationContext) bool { func (c *Cmd) checkCSRFEndpoint(ctx *validationContext) { csrfResp, err := ctx.httpClient.Get(c.config.Kion.URL + "/api/v2/csrf-token") if err != nil { - fmt.Println(ctx.styles.renderCheck("Kion CSRF endpoint is accessible", false)) - fmt.Println(ctx.styles.renderError(err.Error())) - fmt.Println(ctx.styles.renderNote("This is required for SAML authentication")) + fmt.Println(ctx.styles.RenderCheck("Kion CSRF endpoint is accessible", false)) + fmt.Println(ctx.styles.RenderError(err.Error())) + fmt.Println(ctx.styles.RenderNote("This is required for SAML authentication")) ctx.allPassed = false } else { csrfResp.Body.Close() if csrfResp.StatusCode == http.StatusOK { - fmt.Println(ctx.styles.renderCheck("Kion CSRF endpoint is accessible", true)) - fmt.Println(ctx.styles.renderDetail(fmt.Sprintf("Status: HTTP %d", csrfResp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("Kion CSRF endpoint is accessible", true)) + fmt.Println(ctx.styles.RenderDetail(fmt.Sprintf("Status: HTTP %d", csrfResp.StatusCode))) } else { - fmt.Println(ctx.styles.renderCheck("Kion CSRF endpoint is accessible", false)) - fmt.Println(ctx.styles.renderError(fmt.Sprintf("HTTP %d", csrfResp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("Kion CSRF endpoint is accessible", false)) + fmt.Println(ctx.styles.RenderError(fmt.Sprintf("HTTP %d", csrfResp.StatusCode))) ctx.allPassed = false } } @@ -356,14 +189,14 @@ func (c *Cmd) loadMetadata(ctx *validationContext) (*samlTypes.EntityDescriptor, } if err != nil { - fmt.Println(ctx.styles.renderCheck("SAML Metadata is accessible", false)) - fmt.Println(ctx.styles.renderError(err.Error())) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata is accessible", false)) + fmt.Println(ctx.styles.RenderError(err.Error())) ctx.allPassed = false fmt.Println() return nil, err } - fmt.Println(ctx.styles.renderCheck("SAML Metadata is accessible", true)) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata is accessible", true)) return metadata, nil } @@ -391,15 +224,15 @@ func (c *Cmd) validateMetadataStructure(ctx *validationContext, metadata *samlTy } if len(validationErrors) > 0 { - fmt.Println(ctx.styles.renderCheck("SAML Metadata structure is valid", false)) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata structure is valid", false)) for _, errMsg := range validationErrors { - fmt.Println(ctx.styles.renderError(errMsg)) + fmt.Println(ctx.styles.RenderError(errMsg)) } ctx.allPassed = false return false } - fmt.Println(ctx.styles.renderCheck("SAML Metadata structure is valid", true)) + fmt.Println(ctx.styles.RenderCheck("SAML Metadata structure is valid", true)) return true } @@ -416,7 +249,7 @@ func (c *Cmd) printMetadataDetails(ctx *validationContext, metadata *samlTypes.E details.WriteString(fmt.Sprintf("Certificates: %d key descriptor(s) found", len(metadata.IDPSSODescriptor.KeyDescriptors))) } - fmt.Println(ctx.styles.detailsBox.Render(details.String())) + fmt.Println(ctx.styles.DetailsBox.Render(details.String())) } // validateCertificates validates IDP certificates @@ -462,30 +295,30 @@ func (c *Cmd) validateCertificates(ctx *validationContext, metadata *samlTypes.E } if expiredCerts > 0 { - fmt.Println(ctx.styles.renderCheck("IDP certificates are valid", false)) + fmt.Println(ctx.styles.RenderCheck("IDP certificates are valid", false)) for _, errMsg := range certErrors { if strings.Contains(errMsg, "EXPIRED") { - fmt.Println(ctx.styles.renderError(errMsg)) + fmt.Println(ctx.styles.RenderError(errMsg)) } } ctx.allPassed = false } else if expiringSoonCerts > 0 { - fmt.Println(ctx.styles.renderCheck("IDP certificates are valid", true)) - fmt.Println(ctx.styles.renderDetail(fmt.Sprintf("Valid certificates: %d", validCerts))) + fmt.Println(ctx.styles.RenderCheck("IDP certificates are valid", true)) + fmt.Println(ctx.styles.RenderDetail(fmt.Sprintf("Valid certificates: %d", validCerts))) for _, errMsg := range certErrors { if strings.Contains(errMsg, "Expires soon") { - fmt.Println(ctx.styles.renderWarning(errMsg)) + fmt.Println(ctx.styles.RenderWarning(errMsg)) } } } else if len(certErrors) > 0 { - fmt.Println(ctx.styles.renderCheck("IDP certificates are valid", false)) + fmt.Println(ctx.styles.RenderCheck("IDP certificates are valid", false)) for _, errMsg := range certErrors { - fmt.Println(ctx.styles.renderError(errMsg)) + fmt.Println(ctx.styles.RenderError(errMsg)) } ctx.allPassed = false } else { - fmt.Println(ctx.styles.renderCheck("IDP certificates are valid", true)) - fmt.Println(ctx.styles.renderDetail(fmt.Sprintf("All %d certificate(s) are valid and not expiring soon", validCerts))) + fmt.Println(ctx.styles.RenderCheck("IDP certificates are valid", true)) + fmt.Println(ctx.styles.RenderDetail(fmt.Sprintf("All %d certificate(s) are valid and not expiring soon", validCerts))) } } @@ -499,24 +332,97 @@ func (c *Cmd) checkSSOURLReachability(ctx *validationContext, metadata *samlType ssoURL := metadata.IDPSSODescriptor.SingleSignOnServices[0].Location ssoResp, err := ctx.httpClient.Get(ssoURL) if err != nil { - fmt.Println(ctx.styles.renderCheck("IDP SSO URL is reachable", false)) - fmt.Println(ctx.styles.renderError(err.Error())) - fmt.Println(ctx.styles.renderNote("The IDP SSO endpoint may not be accessible from your network")) + fmt.Println(ctx.styles.RenderCheck("IDP SSO URL is reachable", false)) + fmt.Println(ctx.styles.RenderError(err.Error())) + fmt.Println(ctx.styles.RenderNote("The IDP SSO endpoint may not be accessible from your network")) ctx.allPassed = false } else { ssoResp.Body.Close() // Accept any response code < 500, as auth endpoints often return 302, 401, etc. if ssoResp.StatusCode < 500 { - fmt.Println(ctx.styles.renderCheck("IDP SSO URL is reachable", true)) - fmt.Println(ctx.styles.renderDetail(fmt.Sprintf("Status: HTTP %d (IDP is responding)", ssoResp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("IDP SSO URL is reachable", true)) + fmt.Println(ctx.styles.RenderDetail(fmt.Sprintf("Status: HTTP %d (IDP is responding)", ssoResp.StatusCode))) } else { - fmt.Println(ctx.styles.renderCheck("IDP SSO URL is reachable", false)) - fmt.Println(ctx.styles.renderError(fmt.Sprintf("HTTP %d - IDP server error", ssoResp.StatusCode))) + fmt.Println(ctx.styles.RenderCheck("IDP SSO URL is reachable", false)) + fmt.Println(ctx.styles.RenderError(fmt.Sprintf("HTTP %d - IDP server error", ssoResp.StatusCode))) ctx.allPassed = false } } } +//////////////////////////////////////////////////////////////////////////////// +// // +// Commands // +// // +//////////////////////////////////////////////////////////////////////////////// + +// ValidateSAML validates SAML configuration and connectivity. +func (c *Cmd) ValidateSAML(cCtx *cli.Context) error { + ctx := newValidationContext() + + // Header + fmt.Println() + fmt.Println(ctx.styles.RenderMainHeader("SAML Configuration Validation")) + fmt.Println(ctx.styles.RenderSeparator()) + fmt.Println() + + // Check basic configuration + if err := c.checkBasicConfig(ctx); err != nil { + return err + } + + // Check port availability + c.checkPortAvailability(ctx) + + // Check Kion connectivity + kionAccessible := c.checkKionConnectivity(ctx) + + // Load and validate metadata + metadata, err := c.loadMetadata(ctx) + if err == nil { + // Validate metadata structure + if c.validateMetadataStructure(ctx, metadata) { + // Validate certificates + c.validateCertificates(ctx, metadata) + + // Check SSO URL reachability + c.checkSSOURLReachability(ctx, metadata) + } + } + fmt.Println() + + // Check CSRF endpoint if Kion is accessible + if kionAccessible { + c.checkCSRFEndpoint(ctx) + } + + // Summary + fmt.Println(ctx.styles.RenderSeparator()) + if ctx.allPassed { + var summary strings.Builder + summary.WriteString("✓ All validation checks passed!\n\n") + summary.WriteString("Your SAML configuration appears to be correct.\n") + summary.WriteString("Try running SAML authentication to complete the flow.") + + successBox := ctx.styles.SummaryBox.BorderForeground(ctx.styles.CheckMark.GetForeground()) + fmt.Println(successBox.Render(summary.String())) + + // Print metadata details after success message + if metadata != nil { + c.printMetadataDetails(ctx, metadata) + } + return nil + } + + var summary strings.Builder + summary.WriteString("✗ Some validation checks failed.\n\n") + summary.WriteString("Please review the errors above and fix the configuration.") + + failBox := ctx.styles.SummaryBox.BorderForeground(ctx.styles.XMark.GetForeground()) + fmt.Println(failBox.Render(summary.String())) + return fmt.Errorf("SAML validation failed") +} + //////////////////////////////////////////////////////////////////////////////// // // // Validators // diff --git a/lib/helper/prompts.go b/lib/helper/prompts.go index 054b77d..09eba28 100644 --- a/lib/helper/prompts.go +++ b/lib/helper/prompts.go @@ -1,6 +1,41 @@ package helper -import "github.com/AlecAivazis/survey/v2" +import ( + "os" + + "github.com/charmbracelet/huh" + "github.com/kionsoftware/kion-cli/lib/styles" + "golang.org/x/term" +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// Helpers // +// // +//////////////////////////////////////////////////////////////////////////////// + +// shouldLimitHeight determines if the selection prompt height should be +// limited based on the height of the terminal. +func shouldLimitHeight(optionCount int) (bool, int) { + _, termHeight, err := term.GetSize(int(os.Stdout.Fd())) + if err != nil { + // Conservative fallback - limit if more than 10 options + return optionCount > 10, 10 + } + + // Reserve space for title, description, padding, and some buffer + availableLines := termHeight - 8 + + // Ensure at least 3 lines are available for options + availableLines = max(availableLines, 3) + + // Only limit height if options exceed available terminal space + if optionCount > availableLines { + return true, availableLines + } + + return false, 0 +} //////////////////////////////////////////////////////////////////////////////// // // @@ -8,40 +43,76 @@ import "github.com/AlecAivazis/survey/v2" // // //////////////////////////////////////////////////////////////////////////////// -// surveyFormat sets survey icon and color configs. -var surveyFormat = survey.WithIcons(func(icons *survey.IconSet) { - icons.Question.Text = "" - icons.Question.Format = "default+hb" -}) - -// PromptSelect prompts the user to select from a slice of options. It requires -// that the selection made be one of the options provided. -func PromptSelect(message string, options []string) (string, error) { - selection := "" - prompt := &survey.Select{ - Message: message, - Options: options, +// PromptSelect prompts the user to select from a slice of options. It +// requires that the selection made be one of the options provided. +func PromptSelect(message string, description string, options []string) (string, error) { + var selection string + + // Convert to huh options + huhOptions := make([]huh.Option[string], len(options)) + for i, option := range options { + huhOptions[i] = huh.NewOption(option, option) + } + + selectField := huh.NewSelect[string](). + Title(message). + Description(description). + Options(huhOptions...). + Value(&selection) + + // Apply height limiting only if needed + if shouldLimit, height := shouldLimitHeight(len(options)); shouldLimit { + selectField = selectField.Height(height) + } + + form := huh.NewForm( + huh.NewGroup(selectField), + ).WithTheme(styles.FormTheme) + + if err := form.Run(); err != nil { + return "", err } - err := survey.AskOne(prompt, &selection, surveyFormat) - return selection, err + + return selection, nil } // PromptInput prompts the user to provide dynamic input. func PromptInput(message string) (string, error) { var input string - pi := &survey.Input{ - Message: message, + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(message). + Value(&input). + Validate(styles.RequiredValidator), + ), + ).WithTheme(styles.FormTheme) + + if err := form.Run(); err != nil { + return "", err } - err := survey.AskOne(pi, &input, surveyFormat, survey.WithValidator(survey.Required)) - return input, err + + return input, nil } // PromptPassword prompts the user to provide sensitive dynamic input. func PromptPassword(message string) (string, error) { var input string - pi := &survey.Password{ - Message: message, + + form := huh.NewForm( + huh.NewGroup( + huh.NewInput(). + Title(message). + EchoMode(huh.EchoModePassword). + Value(&input). + Validate(styles.RequiredValidator), + ), + ).WithTheme(styles.FormTheme) + + if err := form.Run(); err != nil { + return "", err } - err := survey.AskOne(pi, &input, surveyFormat, survey.WithValidator(survey.Required)) - return input, err + + return input, nil } diff --git a/lib/helper/wizards.go b/lib/helper/wizards.go index 8d35300..acdd8ec 100644 --- a/lib/helper/wizards.go +++ b/lib/helper/wizards.go @@ -30,7 +30,7 @@ func CARSelector(cCtx *cli.Context, car *kion.CAR) error { } // prompt user to select a project - project, err := PromptSelect("Choose a project:", pNames) + project, err := PromptSelect("Choose a project:", "Select the project you want to work with.", pNames) if err != nil { return err } @@ -50,7 +50,7 @@ func CARSelector(cCtx *cli.Context, car *kion.CAR) error { } // prompt user to select an account - account, err := PromptSelect("Choose an Account:", aNames) + account, err := PromptSelect("Choose an Account:", "Select the account for this project.", aNames) if err != nil { return err } @@ -68,7 +68,7 @@ func CARSelector(cCtx *cli.Context, car *kion.CAR) error { } // prompt user to select a car - carname, err := PromptSelect("Choose a Cloud Access Role:", cNames) + carname, err := PromptSelect("Choose a Cloud Access Role:", "Select your cloud access role.", cNames) if err != nil { return err } @@ -102,7 +102,7 @@ func CARSelector(cCtx *cli.Context, car *kion.CAR) error { } // prompt user to select an account - account, err := PromptSelect("Choose an Account:", aNames) + account, err := PromptSelect("Choose an Account:", "Select the account for this project.", aNames) if err != nil { return err } @@ -118,7 +118,7 @@ func CARSelector(cCtx *cli.Context, car *kion.CAR) error { } // prompt user to select a car - carname, err := PromptSelect("Choose a Cloud Access Role:", cNames) + carname, err := PromptSelect("Choose a Cloud Access Role:", "Select your cloud access role.", cNames) if err != nil { return err } @@ -177,13 +177,13 @@ func carSelectorPrivateAPI(cCtx *cli.Context, pMap map[string]kion.Project, proj } // prompt user to select an account - account, err := PromptSelect("Choose an Account:", aNames) + account, err := PromptSelect("Choose an Account:", "Select the account for this project.", aNames) if err != nil { return err } // prompt user to select car - carname, err := PromptSelect("Choose a Cloud Access Role:", aToCMap[account]) + carname, err := PromptSelect("Choose a Cloud Access Role:", "Select your cloud access role.", aToCMap[account]) if err != nil { return err } diff --git a/lib/styles/colors.go b/lib/styles/colors.go new file mode 100644 index 0000000..6e35ab4 --- /dev/null +++ b/lib/styles/colors.go @@ -0,0 +1,96 @@ +// Package styles provides consistent styling and theming for the Kion CLI. +// It defines brand colors, interactive prompt themes, and output formatting +// styles used throughout the application. +package styles + +import "github.com/charmbracelet/lipgloss" + +//////////////////////////////////////////////////////////////////////////////// +// // +// Brand Colors // +// // +//////////////////////////////////////////////////////////////////////////////// + +// Kion brand colors - primary palette +// These use AdaptiveColor to work on both light and dark terminal backgrounds +var ( + // Black is the primary dark color used for backgrounds and contrast + Black = lipgloss.AdaptiveColor{Light: "#101C21", Dark: "#101C21"} + + // Green is the primary accent color used for highlights and selections + Green = lipgloss.AdaptiveColor{Light: "#0D9668", Dark: "#61D7AC"} + + // Mint is used for primary text - light on dark terminals, dark on light terminals + Mint = lipgloss.AdaptiveColor{Light: "#1A202C", Dark: "#F3F7F4"} +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// UI Colors // +// // +//////////////////////////////////////////////////////////////////////////////// + +// Muted colors - secondary palette for less prominent elements +var ( + // MutedMint is a softer color for descriptions and secondary text + MutedMint = lipgloss.AdaptiveColor{Light: "#4A5568", Dark: "#A8B2A5"} + + // MutedGray is used for placeholders and disabled elements + MutedGray = lipgloss.AdaptiveColor{Light: "#718096", Dark: "#6B7B70"} + + // SelectionBg is used for selected item backgrounds + SelectionBg = lipgloss.AdaptiveColor{Light: "#E2E8F0", Dark: "#4A5568"} +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// Status Colors // +// // +//////////////////////////////////////////////////////////////////////////////// + +// Status colors - semantic colors for feedback +// Slightly adjusted between light/dark for optimal visibility +var ( + // Success indicates successful operations + Success = lipgloss.AdaptiveColor{Light: "#0D9668", Dark: "#61D7AC"} + + // Error indicates errors and failures + Error = lipgloss.AdaptiveColor{Light: "#C53030", Dark: "#FF6B6B"} + + // Warning indicates warnings and cautions + Warning = lipgloss.AdaptiveColor{Light: "#B7791F", Dark: "#FFCC66"} + + // Info indicates informational messages + Info = lipgloss.AdaptiveColor{Light: "#2B6CB0", Dark: "#66B2FF"} +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// ANSI Fallback Colors // +// // +//////////////////////////////////////////////////////////////////////////////// + +// ANSI fallback colors - for terminals with limited color support +// These are used by OutputStyles for CLI output formatting +var ( + // ANSIGreen is ANSI color 10 (bright green) + ANSIGreen = lipgloss.Color("10") + + // ANSIRed is ANSI color 9 (bright red) + ANSIRed = lipgloss.Color("9") + + // ANSIYellow is ANSI color 11 (bright yellow) + ANSIYellow = lipgloss.Color("11") + + // ANSIBlue is ANSI color 12 (bright blue) + ANSIBlue = lipgloss.Color("12") + + // ANSICyan is ANSI color 14 (bright cyan) + ANSICyan = lipgloss.Color("14") + + // ANSIGray is ANSI color 245 (medium gray) + ANSIGray = lipgloss.Color("245") + + // ANSIDarkGray is ANSI color 240 (dark gray) + ANSIDarkGray = lipgloss.Color("240") +) diff --git a/lib/styles/output.go b/lib/styles/output.go new file mode 100644 index 0000000..b3ac3ff --- /dev/null +++ b/lib/styles/output.go @@ -0,0 +1,196 @@ +package styles + +import ( + "os" + + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// Output Styles // +// // +//////////////////////////////////////////////////////////////////////////////// + +// OutputStyles holds all lipgloss styles for CLI output formatting. +// Use NewOutputStyles() to create an instance with terminal-aware dimensions. +type OutputStyles struct { + // Status indicators + CheckMark lipgloss.Style + XMark lipgloss.Style + + // Text styles + CheckLabel lipgloss.Style + ErrorText lipgloss.Style + WarningText lipgloss.Style + InfoText lipgloss.Style + DetailText lipgloss.Style + SuccessText lipgloss.Style + + // Headers and sections + MainHeader lipgloss.Style + SectionHeader lipgloss.Style + Separator lipgloss.Style + + // Boxes and containers + DetailsBox lipgloss.Style + SummaryBox lipgloss.Style + + // Layout dimensions + TerminalWidth int + CheckLabelWidth int +} + +// NewOutputStyles creates a new set of output styles with terminal-aware dimensions. +func NewOutputStyles() *OutputStyles { + // Detect terminal width + termWidth := 80 // Default fallback + if width, _, err := term.GetSize(int(os.Stdout.Fd())); err == nil && width > 0 { + termWidth = width + } + + // Use terminal width - 2 for margins, but cap at 80 and minimum 50 + effectiveWidth := max(min(termWidth-2, 80), 50) + + // Calculate check label width: total width - space (1) - checkmark (1) + checkLabelWidth := effectiveWidth - 2 + + // Box width for content (accounting for borders and padding) + boxWidth := checkLabelWidth + 4 + + return &OutputStyles{ + // Status indicators + CheckMark: lipgloss.NewStyle(). + Foreground(ANSIGreen). + Bold(true), + + XMark: lipgloss.NewStyle(). + Foreground(ANSIRed). + Bold(true), + + // Text styles + CheckLabel: lipgloss.NewStyle(). + Width(checkLabelWidth). + Align(lipgloss.Left), + + ErrorText: lipgloss.NewStyle(). + Foreground(ANSIRed). + PaddingLeft(2), + + WarningText: lipgloss.NewStyle(). + Foreground(ANSIYellow). + PaddingLeft(2), + + InfoText: lipgloss.NewStyle(). + Foreground(ANSIBlue). + PaddingLeft(2), + + DetailText: lipgloss.NewStyle(). + Foreground(ANSIGray). + PaddingLeft(2), + + SuccessText: lipgloss.NewStyle(). + Foreground(ANSIGreen). + Bold(true), + + // Headers and sections + MainHeader: lipgloss.NewStyle(). + Foreground(ANSICyan). + Bold(true). + Padding(0, 1), + + SectionHeader: lipgloss.NewStyle(). + Foreground(ANSIBlue). + Bold(true). + PaddingLeft(0). + MarginTop(1), + + Separator: lipgloss.NewStyle(). + Foreground(ANSIDarkGray). + Bold(false), + + // Boxes and containers + DetailsBox: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(ANSIBlue). + PaddingLeft(1). + PaddingRight(1). + Width(boxWidth - 4). + MarginTop(1). + MarginBottom(1), + + SummaryBox: lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(ANSIGreen). + PaddingLeft(1). + PaddingRight(1). + Width(boxWidth - 4). + MarginTop(1). + MarginBottom(1), + + // Dimensions + TerminalWidth: termWidth, + CheckLabelWidth: checkLabelWidth, + } +} + +//////////////////////////////////////////////////////////////////////////////// +// // +// Render Helpers // +// // +//////////////////////////////////////////////////////////////////////////////// + +// RenderCheck renders a check result with label and status indicator. +func (s *OutputStyles) RenderCheck(label string, passed bool) string { + status := s.CheckMark.Render("✓") + if !passed { + status = s.XMark.Render("✗") + } + return s.CheckLabel.Render(label) + " " + status +} + +// RenderDetail renders a detail line (indented gray text). +func (s *OutputStyles) RenderDetail(text string) string { + return s.DetailText.Render(text) +} + +// RenderError renders an error message. +func (s *OutputStyles) RenderError(text string) string { + return s.ErrorText.Render("Error: " + text) +} + +// RenderWarning renders a warning message. +func (s *OutputStyles) RenderWarning(text string) string { + return s.WarningText.Render("Warning: " + text) +} + +// RenderFix renders a fix suggestion. +func (s *OutputStyles) RenderFix(text string) string { + return s.WarningText.Render("Fix: " + text) +} + +// RenderNote renders an informational note. +func (s *OutputStyles) RenderNote(text string) string { + return s.InfoText.Render("Note: " + text) +} + +// RenderSeparator renders a horizontal separator line. +func (s *OutputStyles) RenderSeparator() string { + width := s.CheckLabelWidth + 2 + line := "" + for range width { + line += "─" + } + return s.Separator.Render(line) +} + +// RenderMainHeader renders the main header text. +func (s *OutputStyles) RenderMainHeader(text string) string { + return s.MainHeader.Render(text) +} + +// RenderSectionHeader renders a section header. +func (s *OutputStyles) RenderSectionHeader(text string) string { + return s.SectionHeader.Render(text) +} diff --git a/lib/styles/theme.go b/lib/styles/theme.go new file mode 100644 index 0000000..f86b313 --- /dev/null +++ b/lib/styles/theme.go @@ -0,0 +1,67 @@ +package styles + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +//////////////////////////////////////////////////////////////////////////////// +// // +// Interactive Prompt Theme // +// // +//////////////////////////////////////////////////////////////////////////////// + +// FormTheme is the cached huh theme for interactive prompts. +// It is initialized once at package load time for efficiency. +var FormTheme = newFormTheme() + +// newFormTheme creates the Kion-branded theme for huh forms. +func newFormTheme() *huh.Theme { + t := huh.ThemeBase16() + + // Reusable style builders + greenBold := lipgloss.NewStyle().Foreground(Green).Bold(true) + selected := lipgloss.NewStyle().Foreground(Mint).Background(SelectionBg).Bold(true) + + // Title and header styles + t.Focused.Title = greenBold + t.Focused.NoteTitle = greenBold + + // Selection styles + t.Focused.SelectSelector = greenBold + t.Focused.SelectedOption = selected + t.Focused.SelectedPrefix = greenBold + t.Focused.MultiSelectSelector = lipgloss.NewStyle().Foreground(Green) + + // Option styles + t.Focused.Option = lipgloss.NewStyle().Foreground(Mint) + t.Focused.UnselectedOption = lipgloss.NewStyle().Foreground(Mint) + t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(MutedGray) + + // Text input styles + t.Focused.TextInput.Cursor = lipgloss.NewStyle().Foreground(Green) + t.Focused.TextInput.Prompt = greenBold + t.Focused.TextInput.Placeholder = lipgloss.NewStyle().Foreground(MutedGray) + + // Description and help text + t.Focused.Description = lipgloss.NewStyle().Foreground(MutedMint) + + // Button styles + t.Focused.FocusedButton = selected.Padding(0, 2) + t.Focused.BlurredButton = lipgloss.NewStyle().Foreground(MutedGray).Bold(true).Padding(0, 2) + + // Error styles + t.Focused.ErrorMessage = lipgloss.NewStyle().Foreground(Error).Bold(true) + t.Focused.ErrorIndicator = lipgloss.NewStyle().Foreground(Error) + + return t +} + +//////////////////////////////////////////////////////////////////////////////// +// // +// Validation Helpers // +// // +//////////////////////////////////////////////////////////////////////////////// + +// RequiredValidator is a reusable validator that ensures input is not empty. +var RequiredValidator = huh.ValidateNotEmpty() diff --git a/tools/api-proxy/main.go b/tools/api-proxy/main.go new file mode 100644 index 0000000..7bcca09 --- /dev/null +++ b/tools/api-proxy/main.go @@ -0,0 +1,57 @@ +// API proxy server that forwards requests with /api prefix. For testing with a +// local instance of Kion. +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "net/http/httputil" + "net/url" + "strings" +) + +func main() { + // Parse command line flags + targetPort := flag.String("target", "8081", "Target server port (default: 8081)") + proxyPort := flag.String("port", "7979", "Proxy listen port (default: 7979)") + flag.Parse() + + // Set up target URL + targetURL := fmt.Sprintf("http://localhost:%s", *targetPort) + target, err := url.Parse(targetURL) + if err != nil { + log.Fatalf("Failed to parse target URL: %v", err) + } + + // Create reverse proxy + proxy := httputil.NewSingleHostReverseProxy(target) + + // Custom director to strip /api prefix + originalDirector := proxy.Director + proxy.Director = func(req *http.Request) { + originalDirector(req) + // Strip /api prefix from the path + req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api") + if req.URL.Path == "" { + req.URL.Path = "/" + } + log.Printf("Proxying: %s %s -> %s%s", req.Method, req.RequestURI, targetURL, req.URL.Path) + } + + // Set up HTTP handler + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + proxy.ServeHTTP(w, r) + }) + + // Start server + listenAddr := fmt.Sprintf(":%s", *proxyPort) + log.Printf("Starting API proxy on http://localhost%s", listenAddr) + log.Printf("Forwarding requests to %s (stripping /api prefix)", targetURL) + log.Printf("Example: http://localhost%s/api/v3/accounts -> %s/v3/accounts", *proxyPort, targetURL) + + if err := http.ListenAndServe(listenAddr, nil); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +}