diff --git a/.github/assets/demo.gif b/.github/assets/demo.gif new file mode 100644 index 0000000..d7e0b94 Binary files /dev/null and b/.github/assets/demo.gif differ diff --git a/.github/workflows/chocolatey.yml b/.github/workflows/chocolatey.yml index 90a06ab..c184688 100644 --- a/.github/workflows/chocolatey.yml +++ b/.github/workflows/chocolatey.yml @@ -23,13 +23,13 @@ jobs: if: ${{ github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')) }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Unshallow run: git fetch --prune --unshallow - name: Get latest release tag - uses: oprypin/find-latest-tag@v1 + uses: oprypin/find-latest-tag@dd2729fe78b0bb55523ae2b2a310c6773a652bd1 # v1.1.2 with: repository: ${{ github.repository }} releases-only: true @@ -60,21 +60,21 @@ jobs: ls -la nuspec/chocolatey/tools/ - name: Choco Downgrade - uses: crazy-max/ghaction-chocolatey@v3 + uses: crazy-max/ghaction-chocolatey@2526f467ccbd337d307fe179959cabbeca0bc8c0 # v3.4.0 with: args: install chocolatey --version=1.2.1 --allow-downgrade -y -r --no-progress - name: Pack Release - uses: crazy-max/ghaction-chocolatey@v3 + uses: crazy-max/ghaction-chocolatey@2526f467ccbd337d307fe179959cabbeca0bc8c0 # v3.4.0 with: args: pack nuspec/chocolatey/okta-aws-cli.nuspec --outputdirectory nuspec/chocolatey - name: Choco Upgrade - uses: crazy-max/ghaction-chocolatey@v3 + uses: crazy-max/ghaction-chocolatey@2526f467ccbd337d307fe179959cabbeca0bc8c0 # v3.4.0 with: args: upgrade chocolatey - name: Upload Release - uses: crazy-max/ghaction-chocolatey@v3 + uses: crazy-max/ghaction-chocolatey@2526f467ccbd337d307fe179959cabbeca0bc8c0 # v3.4.0 with: args: push nuspec/chocolatey/okta-aws-cli.${{ steps.version.outputs.nuget }}.nupkg -s https://push.chocolatey.org/ -k ${{ secrets.CHOCO_API_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f1a0c..8e9f100 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: - name: Setup Go uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 with: - go-version: 1.21 + go-version-file: "go.mod" - name: Setup Go Tools run: make tools diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f2cb37..15d78de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@6edd4406fa81c3da01a34fa6f6343087c207a568 #v3.5.0 with: - go-version: 1.21 + go-version-file: "go.mod" - name: Import GPG key id: import_gpg diff --git a/README.md b/README.md index 30f3570..e4e9d37 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,23 @@ These settings are optional unless marked otherwise: | Authorization Server ID | The ID of the Okta authorization server, set ID for a custom authorization server, will use default otherwise. Default `default` | `--authz-id [value]` | `OKTA_AWSCLI_AUTHZ_ID` | | Custom STS Role Session Name | Customize STS Role Session Name. Default `okta-aws-cli` | `--aws-sts-role-session-name [value]` | `OKTA_AWSCLI_STS_ROLE_SESSION_NAME` | +### Interactive Selection with Fuzzy Search + +When multiple IdPs or Roles are available, `okta-aws-cli` presents an interactive +picker with fuzzy search capabilities. This makes it easy to find the right +option even when you have many roles configured. + +![Fuzzy Search Picker Demo](.github/assets/demo.gif) + +**Features:** +- **Fuzzy search**: Type to filter options - matches are highlighted +- **Smart filtering**: Search is performed on the role/IdP name only (the part after the last `/`), not the full ARN +- **Keyboard navigation**: Use `↑`/`↓` arrows to navigate, `Enter` to select, `Esc` to cancel +- **Scroll indicators**: Shows count of items above/below when list is long + +When typing `dev`, only roles containing "dev" in the role name will be shown, +with the matching characters highlighted. + ### Friendly IdP and Role menu labels When the operator has many AWS Federation apps listing the AWS IAM IdP ARNs can @@ -591,18 +608,28 @@ and Roles can also be evaluated are regular expressions (see example below). `$HOME/.okta/okta.yaml` as a configuration file and location. We will continue that practice with read-only friendly okta-aws-cli application values. -#### Before +#### Before (without friendly labels) ``` -? Choose an IdP: [Use arrows to move, type to filter] +? Choose an IdP: + + > Type to filter... + > Fed App 1 Label Fed App 2 Label Fed App 3 Label Fed App 4 Label -? Choose a Role: [Use arrows to move, type to filter] + (esc to cancel) + +? Choose a Role: + + > Type to filter... + > arn:aws:iam::123456789012:role/admin arn:aws:iam::123456789012:role/ops + + (esc to cancel) ``` #### Example `$HOME/.okta/okta.yaml` @@ -620,18 +647,28 @@ awscli: "arn:aws:iam::.*:role/operator": "Ops" ``` -#### After +#### After (with friendly labels from okta.yaml) ``` -? Choose an IdP: [Use arrows to move, type to filter] +? Choose an IdP: + + > Type to filter... + > Data Production Data Development Marketing Production Marketing Development -? Choose a Role: [Use arrows to move, type to filter] + (esc to cancel) + +? Choose a Role: + + > Type to filter... + > Admin Ops + + (esc to cancel) ``` ### Configuration by profile name diff --git a/examples/picker_demo.go b/examples/picker_demo.go new file mode 100644 index 0000000..12757a1 --- /dev/null +++ b/examples/picker_demo.go @@ -0,0 +1,72 @@ +//go:build ignore +// +build ignore + +/* + * Copyright (c) 2026-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// This is a demo script to showcase the fuzzy search picker functionality. +// Run this demo to see how the picker works with example AWS IAM roles. +// +// Usage: +// +// go run examples/picker_demo.go +package main + +import ( + "fmt" + "os" + + "github.com/okta/okta-aws-cli/v2/internal/picker" +) + +func main() { + // Example AWS IAM roles with fake account IDs for demonstration + roles := []string{ + "arn:aws:iam::123456789012:role/Admin", + "arn:aws:iam::123456789012:role/Developer", + "arn:aws:iam::123456789012:role/ReadOnly", + "arn:aws:iam::123456789012:role/DevOps-Engineer", + "arn:aws:iam::123456789012:role/Security-Analyst", + "arn:aws:iam::987654321098:role/Production-Admin", + "arn:aws:iam::987654321098:role/Production-Developer", + "arn:aws:iam::987654321098:role/Production-ReadOnly", + "arn:aws:iam::111222333444:role/Staging-Admin", + "arn:aws:iam::111222333444:role/Staging-Developer", + "arn:aws:iam::555666777888:role/QA-Tester", + "arn:aws:iam::555666777888:role/QA-Lead", + "arn:aws:iam::999000111222:role/Data-Engineer", + "arn:aws:iam::999000111222:role/Data-Scientist", + "arn:aws:iam::999000111222:role/ML-Engineer", + } + + fmt.Println("=== Okta AWS CLI - Fuzzy Search Picker Demo ===") + fmt.Println() + fmt.Println("Tips:") + fmt.Println(" • Type to filter roles (searches role name, not full ARN)") + fmt.Println(" • Use ↑/↓ arrows to navigate") + fmt.Println(" • Press Enter to select") + fmt.Println(" • Press Esc to cancel") + fmt.Println() + + selected, err := picker.Pick("Choose an AWS IAM Role:", roles) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + fmt.Println() + fmt.Printf("✓ Selected: %s\n", selected) +} diff --git a/go.mod b/go.mod index 50bf923..eb89ec0 100644 --- a/go.mod +++ b/go.mod @@ -1,32 +1,55 @@ module github.com/okta/okta-aws-cli/v2 -go 1.21 +go 1.24.2 require ( - github.com/AlecAivazis/survey/v2 v2.3.6 github.com/BurntSushi/toml v1.4.0 github.com/aws/aws-sdk-go v1.55.5 github.com/cenkalti/backoff/v4 v4.1.3 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/go-jose/go-jose/v4 v4.0.4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.1.2 github.com/logrusorgru/aurora v2.0.3+incompatible - github.com/mattn/go-isatty v0.0.16 + github.com/mattn/go-isatty v0.0.20 github.com/mdp/qrterminal v1.0.1 github.com/ompluscator/dynamic-struct v1.4.0 github.com/pkg/errors v0.9.1 + github.com/sahilm/fuzzy v0.1.1 github.com/spf13/cobra v1.5.0 github.com/spf13/viper v1.14.0 github.com/stretchr/testify v1.9.0 github.com/tidwall/pretty v1.2.0 - golang.org/x/net v0.34.0 - golang.org/x/sys v0.29.0 + golang.org/x/net v0.49.0 + golang.org/x/sys v0.41.0 gopkg.in/dnaeon/go-vcr.v3 v3.1.2 gopkg.in/ini.v1 v1.67.0 gopkg.in/yaml.v2 v2.4.0 ) -require golang.org/x/crypto v0.32.0 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // 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/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.48.0 // indirect +) require ( github.com/davecgh/go-spew v1.1.1 // indirect @@ -34,10 +57,7 @@ require ( github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/magiconair/properties v1.8.7 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect - github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect @@ -48,8 +68,7 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - golang.org/x/term v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index b2c8516..dd11380 100644 --- a/go.sum +++ b/go.sum @@ -36,29 +36,47 @@ cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RX cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -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/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU= github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= +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/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= -github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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= @@ -68,6 +86,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +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/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= @@ -140,8 +160,6 @@ github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -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/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= @@ -153,8 +171,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -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/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -164,24 +180,30 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8= github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= -github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= -github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= -github.com/mattn/go-isatty v0.0.16/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-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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mdp/qrterminal v1.0.1 h1:07+fzVDlPuBlXS8tB0ktTAyf+Lp1j2+2zK3fBOL5b7c= github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ= -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/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/ompluscator/dynamic-struct v1.4.0 h1:I/Si9LZtItSwiTMe7vosEuIu2TKdOvWbE3R/lokpN4Q= github.com/ompluscator/dynamic-struct v1.4.0/go.mod h1:ADQ1+6Ox1D+ntuNwTHyl1NvpAqY2lBXPSPbcO4CJdeA= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -196,10 +218,14 @@ github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qR github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 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/spf13/afero v1.9.4 h1:Sd43wM1IWz/s1aVXdOBkjJvuP8UdyqioeE4AmM0QsBs= github.com/spf13/afero v1.9.4/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= @@ -218,7 +244,6 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -229,6 +254,8 @@ github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +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/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -246,8 +273,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +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-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -258,6 +285,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +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/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -312,8 +341,8 @@ golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -335,7 +364,6 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -369,25 +397,20 @@ golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/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= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= -golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/internal/m2mauth/m2mauth_test.go b/internal/m2mauth/m2mauth_test.go index f3bc3e3..5a01245 100644 --- a/internal/m2mauth/m2mauth_test.go +++ b/internal/m2mauth/m2mauth_test.go @@ -17,6 +17,10 @@ package m2mauth import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" "net/http" "os" "regexp" @@ -27,6 +31,26 @@ import ( "github.com/stretchr/testify/require" ) +// generateTestPrivateKey creates a 2048-bit RSA private key in PKCS8 PEM format for testing +func generateTestPrivateKey() (string, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", err + } + + pkcs8Bytes, err := x509.MarshalPKCS8PrivateKey(privateKey) + if err != nil { + return "", err + } + + pemBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: pkcs8Bytes, + } + + return string(pem.EncodeToMemory(pemBlock)), nil +} + func TestMain(m *testing.M) { var reset func() reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_ORG_DOMAIN", testutils.TestDomainName) @@ -42,20 +66,12 @@ func TestMain(m *testing.M) { reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_KEY_ID", "kid-rock") defer reset() - // NOTE: Okta Security this is just some random PK to unit test the client - // assertion generator in this app. PK was created with - // `openssl genrsa 512 | pbcopy` - reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_PRIVATE_KEY", ` ------BEGIN PRIVATE KEY----- -MIIBVQIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEAzAZ73GY6TbcC0cQS -LQ+GfIkZxeTJjkW8+pdg0zmcGs4ZByZqp7oP02TbZ0UyLFHe8Eqik5rXR98mts5e -TuG2BwIDAQABAkEAmG2jrjdGCffYCGYnejjmLjaz5bCXkU6y8LmWIlkhMrg/F7uH -/yjmN3Hcj06F4b2DRczIIxWHpZVeFaqxvitZ6QIhAPlxhYIIpx4h+mf7cPXOlCZc -QDRqIa+pp3JH3Pgrz8mzAiEA0WNZP8acq251xTl2i+OrstH0o3YeYUmASv8bmyNs -0F0CIALSAsVunZ0cmz0zvZo55LjuUBeHn6vhyi/jmh8AN9A7AiEAoNtM1iTTeROb -4A7cFm2qGu8WnHkCr8SSjYrb/1vAnXUCIFgT6wGO6AFjQAahQlpVnqpppP9F8eSd -qrebTIkNMM8u ------END PRIVATE KEY-----`) + // Generate a fresh RSA key for testing JWT signing + privateKey, err := generateTestPrivateKey() + if err != nil { + panic("failed to generate test private key: " + err.Error()) + } + reset = testutils.OsSetEnvIfBlank("OKTA_AWSCLI_PRIVATE_KEY", privateKey) defer reset() os.Exit(m.Run()) diff --git a/internal/picker/picker.go b/internal/picker/picker.go new file mode 100644 index 0000000..fe33038 --- /dev/null +++ b/internal/picker/picker.go @@ -0,0 +1,371 @@ +/* + * Copyright (c) 2026-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Package picker provides a fuzzy search picker using bubbletea +package picker + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sahilm/fuzzy" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("39")) + + selectedStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")). + Bold(true) + + normalStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")) + + matchStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("39")). + Bold(true) + + dimStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")) + + cursorStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("170")) +) + +// Model represents the picker state +type Model struct { + title string + items []string + searchKeys []string // extracted search terms (e.g., role name from ARN) + filtered []int // indices into items + matches []fuzzy.Match + cursor int + selected string + textInput textinput.Model + quitting bool + cancelled bool + windowHeight int + maxVisible int +} + +// extractSearchKey extracts the searchable part from an item +// For ARNs like "arn:aws:iam::123:role/AdminRole", returns "AdminRole" +// For other strings, returns the last part after "/" +func extractSearchKey(item string) string { + if idx := strings.LastIndex(item, "/"); idx != -1 && idx < len(item)-1 { + return item[idx+1:] + } + return item +} + +// New creates a new picker model +func New(title string, items []string) Model { + ti := textinput.New() + ti.Placeholder = "Type to search..." + ti.Focus() + ti.CharLimit = 100 + ti.Width = 50 + ti.PromptStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) + ti.TextStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + + searchKeys := make([]string, len(items)) + filtered := make([]int, len(items)) + for i, item := range items { + searchKeys[i] = extractSearchKey(item) + filtered[i] = i + } + + return Model{ + title: title, + items: items, + searchKeys: searchKeys, + filtered: filtered, + textInput: ti, + maxVisible: 15, + } +} + +// Init implements tea.Model +func (m Model) Init() tea.Cmd { + return textinput.Blink +} + +// Update implements tea.Model +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowHeight = msg.Height + m.maxVisible = min(15, msg.Height-6) + if m.maxVisible < 3 { + m.maxVisible = 3 + } + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "esc": + m.cancelled = true + m.quitting = true + return m, tea.Quit + + case "enter": + if len(m.filtered) > 0 && m.cursor < len(m.filtered) { + m.selected = m.items[m.filtered[m.cursor]] + } + m.quitting = true + return m, tea.Quit + + case "up", "ctrl+p": + if m.cursor > 0 { + m.cursor-- + } + return m, nil + + case "down", "ctrl+n": + if m.cursor < len(m.filtered)-1 { + m.cursor++ + } + return m, nil + + case "pgup": + m.cursor -= m.maxVisible + if m.cursor < 0 { + m.cursor = 0 + } + return m, nil + + case "pgdown": + m.cursor += m.maxVisible + if m.cursor >= len(m.filtered) { + m.cursor = len(m.filtered) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + return m, nil + + case "home", "ctrl+a": + m.cursor = 0 + return m, nil + + case "end", "ctrl+e": + if len(m.filtered) > 0 { + m.cursor = len(m.filtered) - 1 + } + return m, nil + } + } + + prevValue := m.textInput.Value() + m.textInput, cmd = m.textInput.Update(msg) + + if m.textInput.Value() != prevValue { + m.filter() + m.cursor = 0 + } + + return m, cmd +} + +func (m *Model) filter() { + query := m.textInput.Value() + if query == "" { + m.filtered = make([]int, len(m.items)) + for i := range m.items { + m.filtered[i] = i + } + m.matches = nil + return + } + + matches := fuzzy.Find(query, m.searchKeys) + m.matches = matches + m.filtered = make([]int, len(matches)) + for i, match := range matches { + m.filtered[i] = match.Index + } +} + +// View implements tea.Model +func (m Model) View() string { + if m.quitting { + return "" + } + + var b strings.Builder + + b.WriteString(titleStyle.Render("? "+m.title) + "\n\n") + b.WriteString(" " + m.textInput.View() + "\n\n") + + if len(m.filtered) == 0 { + b.WriteString(dimStyle.Render(" No matches found\n")) + } else { + start := 0 + end := len(m.filtered) + + if len(m.filtered) > m.maxVisible { + half := m.maxVisible / 2 + start = max(m.cursor-half, 0) + end = start + m.maxVisible + if end > len(m.filtered) { + end = len(m.filtered) + start = max(end-m.maxVisible, 0) + } + } + + if start > 0 { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↑ %d more above\n", start))) + } + + for i := start; i < end; i++ { + itemIndex := m.filtered[i] + cursor := " " + if i == m.cursor { + cursor = cursorStyle.Render("> ") + } + + displayItem := m.renderItem(i, itemIndex) + b.WriteString(cursor + displayItem + "\n") + } + + if end < len(m.filtered) { + b.WriteString(dimStyle.Render(fmt.Sprintf(" ↓ %d more below\n", len(m.filtered)-end))) + } + } + + b.WriteString("\n") + b.WriteString(dimStyle.Render(fmt.Sprintf(" %d/%d", len(m.filtered), len(m.items)))) + b.WriteString(dimStyle.Render(" • ↑/↓ navigate • enter select • esc cancel")) + + return b.String() +} + +func (m Model) renderItem(filteredIndex int, itemIndex int) string { + isSelected := filteredIndex == m.cursor + hasQuery := m.textInput.Value() != "" + fullItem := m.items[itemIndex] + searchKey := m.searchKeys[itemIndex] + + if hasQuery && filteredIndex < len(m.matches) { + return m.highlightInFullItem(fullItem, searchKey, m.matches[filteredIndex], isSelected) + } + + if isSelected { + return selectedStyle.Render(fullItem) + } + return normalStyle.Render(fullItem) +} + +// highlightInFullItem highlights matched characters in the full item. +// The match was performed against searchKey, but we display fullItem. +// We find the searchKey suffix in fullItem and apply highlighting there. +func (m Model) highlightInFullItem(fullItem, searchKey string, match fuzzy.Match, isSelected bool) string { + // Find where searchKey appears in fullItem (should be at the end after last /) + keyStart := strings.LastIndex(fullItem, searchKey) + if keyStart == -1 { + // Fallback: searchKey not found, just render without highlight + if isSelected { + return selectedStyle.Render(fullItem) + } + return normalStyle.Render(fullItem) + } + + // Build result with highlighting only in the searchKey portion + var result strings.Builder + + // Prefix part (before searchKey) + prefix := fullItem[:keyStart] + if isSelected { + result.WriteString(selectedStyle.Render(prefix)) + } else { + result.WriteString(normalStyle.Render(prefix)) + } + + // SearchKey part with match highlighting + matchSet := make(map[int]bool) + for _, idx := range match.MatchedIndexes { + matchSet[idx] = true + } + + for i, char := range searchKey { + if matchSet[i] { + result.WriteString(matchStyle.Render(string(char))) + } else if isSelected { + result.WriteString(selectedStyle.Render(string(char))) + } else { + result.WriteString(normalStyle.Render(string(char))) + } + } + + // Suffix part (after searchKey, if any) + suffix := fullItem[keyStart+len(searchKey):] + if len(suffix) > 0 { + if isSelected { + result.WriteString(selectedStyle.Render(suffix)) + } else { + result.WriteString(normalStyle.Render(suffix)) + } + } + + return result.String() +} + +// Selected returns the selected item +func (m Model) Selected() string { + return m.selected +} + +// Cancelled returns true if the user cancelled +func (m Model) Cancelled() bool { + return m.cancelled +} + +// Pick runs the picker and returns the selected item +func Pick(title string, items []string) (string, error) { + if len(items) == 0 { + return "", fmt.Errorf("no items to select from") + } + + if len(items) == 1 { + return items[0], nil + } + + m := New(title, items) + p := tea.NewProgram(m, tea.WithOutput(os.Stderr)) + + finalModel, err := p.Run() + if err != nil { + return "", fmt.Errorf("error running picker: %w", err) + } + + result := finalModel.(Model) + if result.Cancelled() { + return "", fmt.Errorf("selection cancelled") + } + + if result.Selected() == "" { + return "", fmt.Errorf("no item selected") + } + + return result.Selected(), nil +} diff --git a/internal/picker/picker_test.go b/internal/picker/picker_test.go new file mode 100644 index 0000000..7860c48 --- /dev/null +++ b/internal/picker/picker_test.go @@ -0,0 +1,480 @@ +/* + * Copyright (c) 2026-Present, Okta, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package picker + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/require" +) + +func TestExtractSearchKey(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "ARN with role", + input: "arn:aws:iam::123456789012:role/AdminRole", + expected: "AdminRole", + }, + { + name: "ARN with SAML provider", + input: "arn:aws:iam::123456789012:saml-provider/Okta", + expected: "Okta", + }, + { + name: "simple string without slash", + input: "DataProduction", + expected: "DataProduction", + }, + { + name: "friendly label with spaces", + input: "Data Production Environment", + expected: "Data Production Environment", + }, + { + name: "path with multiple slashes", + input: "a/b/c/d/LastPart", + expected: "LastPart", + }, + { + name: "empty string", + input: "", + expected: "", + }, + { + name: "trailing slash", + input: "something/", + expected: "something/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractSearchKey(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + +func TestNewModel(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + "arn:aws:iam::456:role/ReadOnly", + } + + m := New("Choose a Role:", items) + + require.Equal(t, "Choose a Role:", m.title) + require.Equal(t, items, m.items) + require.Len(t, m.searchKeys, 3) + require.Equal(t, "Admin", m.searchKeys[0]) + require.Equal(t, "Developer", m.searchKeys[1]) + require.Equal(t, "ReadOnly", m.searchKeys[2]) + require.Len(t, m.filtered, 3) + require.Equal(t, 0, m.cursor) + require.Empty(t, m.selected) + require.False(t, m.quitting) + require.False(t, m.cancelled) +} + +func TestModelFilter(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/AdminRole", + "arn:aws:iam::123:role/DeveloperRole", + "arn:aws:iam::456:role/ReadOnlyRole", + "arn:aws:iam::456:role/AdminAccess", + } + + m := New("Choose a Role:", items) + + require.Len(t, m.filtered, 4) + + // Simulate typing "admin" + m.textInput.SetValue("admin") + m.filter() + + // Should match "AdminRole" and "AdminAccess" + require.Len(t, m.filtered, 2) + + // Verify the filtered indices point to correct items + for _, idx := range m.filtered { + searchKey := m.searchKeys[idx] + require.Contains(t, []string{"AdminRole", "AdminAccess"}, searchKey) + } +} + +func TestModelFilterCaseInsensitive(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/AdminRole", + "arn:aws:iam::123:role/adminaccess", + "arn:aws:iam::456:role/ADMINISTRATOR", + } + + m := New("Choose a Role:", items) + + // Fuzzy search is case-insensitive + m.textInput.SetValue("ADMIN") + m.filter() + + require.Len(t, m.filtered, 3) +} + +func TestModelFilterNoMatches(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + } + + m := New("Choose a Role:", items) + + m.textInput.SetValue("xyz123") + m.filter() + + require.Len(t, m.filtered, 0) +} + +func TestModelFilterClearQuery(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + } + + m := New("Choose a Role:", items) + + // Filter to one item + m.textInput.SetValue("admin") + m.filter() + require.Len(t, m.filtered, 1) + + // Clear filter + m.textInput.SetValue("") + m.filter() + require.Len(t, m.filtered, 2) +} + +func TestModelUpdateKeyNavigation(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + "arn:aws:iam::456:role/ReadOnly", + } + + m := New("Choose a Role:", items) + require.Equal(t, 0, m.cursor) + + // Move down + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = newModel.(Model) + require.Equal(t, 1, m.cursor) + + // Move down again + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = newModel.(Model) + require.Equal(t, 2, m.cursor) + + // Try to move past the end + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = newModel.(Model) + require.Equal(t, 2, m.cursor) + + // Move up + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = newModel.(Model) + require.Equal(t, 1, m.cursor) + + // Move to beginning + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyHome}) + m = newModel.(Model) + require.Equal(t, 0, m.cursor) + + // Try to move before the beginning + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyUp}) + m = newModel.(Model) + require.Equal(t, 0, m.cursor) + + // Move to end + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnd}) + m = newModel.(Model) + require.Equal(t, 2, m.cursor) +} + +func TestModelUpdateEnterSelection(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + } + + m := New("Choose a Role:", items) + + // Move to second item + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown}) + m = newModel.(Model) + + // Press enter + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = newModel.(Model) + + require.True(t, m.quitting) + require.False(t, m.cancelled) + require.Equal(t, "arn:aws:iam::123:role/Developer", m.selected) +} + +func TestModelUpdateEscCancel(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + } + + m := New("Choose a Role:", items) + + // Press escape + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + m = newModel.(Model) + + require.True(t, m.quitting) + require.True(t, m.cancelled) + require.Empty(t, m.selected) +} + +func TestModelUpdateCtrlCCancel(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + } + + m := New("Choose a Role:", items) + + // Press ctrl+c + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + m = newModel.(Model) + + require.True(t, m.quitting) + require.True(t, m.cancelled) +} + +func TestModelSelectedAndCancelled(t *testing.T) { + items := []string{"item1", "item2"} + m := New("Title", items) + + require.Empty(t, m.Selected()) + require.False(t, m.Cancelled()) + + // Simulate selection + m.selected = "item1" + require.Equal(t, "item1", m.Selected()) + + // Simulate cancellation + m.cancelled = true + require.True(t, m.Cancelled()) +} + +func TestModelViewNotQuitting(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/Developer", + } + + m := New("Choose a Role:", items) + view := m.View() + + // Should contain title + require.Contains(t, view, "Choose a Role:") + + // Should contain items + require.Contains(t, view, "Admin") + require.Contains(t, view, "Developer") + + // Should contain navigation hints + require.Contains(t, view, "navigate") + require.Contains(t, view, "select") + require.Contains(t, view, "cancel") + + // Should show item count + require.Contains(t, view, "2/2") +} + +func TestModelViewQuitting(t *testing.T) { + items := []string{"item1"} + m := New("Title", items) + m.quitting = true + + view := m.View() + require.Empty(t, view) +} + +func TestModelViewNoMatches(t *testing.T) { + items := []string{"item1", "item2"} + m := New("Title", items) + + m.textInput.SetValue("xyz") + m.filter() + + view := m.View() + require.Contains(t, view, "No matches found") + require.Contains(t, view, "0/2") +} + +func TestModelPageUpPageDown(t *testing.T) { + // Create a list larger than maxVisible + items := make([]string, 30) + for i := 0; i < 30; i++ { + items[i] = "item" + string(rune('A'+i)) + } + + m := New("Choose:", items) + m.maxVisible = 10 + require.Equal(t, 0, m.cursor) + + // Page down + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m = newModel.(Model) + require.Equal(t, 10, m.cursor) + + // Page down again + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m = newModel.(Model) + require.Equal(t, 20, m.cursor) + + // Page down - should stop at last item + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgDown}) + m = newModel.(Model) + require.Equal(t, 29, m.cursor) + + // Page up + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + m = newModel.(Model) + require.Equal(t, 19, m.cursor) + + // Page up multiple times to reach beginning + for i := 0; i < 5; i++ { + newModel, _ = m.Update(tea.KeyMsg{Type: tea.KeyPgUp}) + m = newModel.(Model) + } + require.Equal(t, 0, m.cursor) +} + +func TestModelFilterThenSelect(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/AdminRole", + "arn:aws:iam::123:role/DeveloperRole", + "arn:aws:iam::456:role/ReadOnlyRole", + } + + m := New("Choose a Role:", items) + + // Filter to "dev" + m.textInput.SetValue("dev") + m.filter() + m.cursor = 0 // Reset cursor after filter + + require.Len(t, m.filtered, 1) + + // Press enter to select the filtered item + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = newModel.(Model) + + require.Equal(t, "arn:aws:iam::123:role/DeveloperRole", m.selected) +} + +func TestModelWindowSizeUpdate(t *testing.T) { + items := []string{"item1", "item2"} + m := New("Title", items) + + // Simulate window size message + newModel, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 24}) + m = newModel.(Model) + + require.Equal(t, 24, m.windowHeight) + require.Equal(t, 15, m.maxVisible) // min(15, 24-6) = 15 +} + +func TestModelWindowSizeSmall(t *testing.T) { + items := []string{"item1", "item2"} + m := New("Title", items) + + // Simulate small window + newModel, _ := m.Update(tea.WindowSizeMsg{Width: 80, Height: 8}) + m = newModel.(Model) + + require.Equal(t, 3, m.maxVisible) // min(15, 8-6) = 2, but min is 3 +} + +func TestPickSingleItem(t *testing.T) { + items := []string{"only-one-item"} + + // Pick should return the single item without running the UI + result, err := Pick("Choose:", items) + + require.NoError(t, err) + require.Equal(t, "only-one-item", result) +} + +func TestPickEmptyItems(t *testing.T) { + items := []string{} + + _, err := Pick("Choose:", items) + + require.Error(t, err) + require.Contains(t, err.Error(), "no items to select from") +} + +func TestFuzzyMatchOrdering(t *testing.T) { + items := []string{ + "arn:aws:iam::123:role/ProductionAdmin", + "arn:aws:iam::123:role/Admin", + "arn:aws:iam::123:role/AdminBackup", + } + + m := New("Choose:", items) + + m.textInput.SetValue("admin") + m.filter() + + // Should have all 3 matches + require.Len(t, m.filtered, 3) + + // The fuzzy library scores exact/shorter matches higher + // "Admin" should be ranked higher than "ProductionAdmin" + firstMatch := m.items[m.filtered[0]] + require.Contains(t, firstMatch, "Admin") +} + +func TestSearchKeyPreservesOriginalForDisplay(t *testing.T) { + items := []string{ + "arn:aws:iam::123456789012:role/MyAdminRole", + } + + m := New("Choose:", items) + + // Search key should be just the role name + require.Equal(t, "MyAdminRole", m.searchKeys[0]) + + // But items should preserve full ARN + require.Equal(t, "arn:aws:iam::123456789012:role/MyAdminRole", m.items[0]) + + // After filtering, selected item should be full ARN + m.textInput.SetValue("admin") + m.filter() + + newModel, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m = newModel.(Model) + + require.Equal(t, "arn:aws:iam::123456789012:role/MyAdminRole", m.selected) +} diff --git a/internal/webssoauth/webssoauth.go b/internal/webssoauth/webssoauth.go index 39c5af0..11dceb4 100644 --- a/internal/webssoauth/webssoauth.go +++ b/internal/webssoauth/webssoauth.go @@ -33,15 +33,13 @@ import ( "sync" "time" - "github.com/AlecAivazis/survey/v2" - "github.com/AlecAivazis/survey/v2/core" - "github.com/AlecAivazis/survey/v2/terminal" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/iam" "github.com/aws/aws-sdk-go/service/sts" "github.com/cenkalti/backoff/v4" + "github.com/charmbracelet/lipgloss" "github.com/google/shlex" "github.com/mdp/qrterminal" brwsr "github.com/pkg/browser" @@ -54,9 +52,15 @@ import ( "github.com/okta/okta-aws-cli/v2/internal/okta" "github.com/okta/okta-aws-cli/v2/internal/output" "github.com/okta/okta-aws-cli/v2/internal/paginator" + "github.com/okta/okta-aws-cli/v2/internal/picker" "github.com/okta/okta-aws-cli/v2/internal/utils" ) +var ( + labelStyle = lipgloss.NewStyle().Bold(true) + valueStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) +) + const ( amazonAWS = "amazon_aws" nameKey = "name" @@ -70,19 +74,10 @@ const ( noRolesError = "no roles chosen for provider %q" chooseIDP = "Choose an IdP:" chooseRole = "Choose a Role:" - idpSelectedTemplate = ` {{color "default+hb"}}IdP: {{color "reset"}}{{color "cyan"}}{{ .IDP }}{{color "reset"}}` - roleSelectedTemplate = ` {{color "default+hb"}}Role: {{color "reset"}}{{color "cyan"}}{{ .Role }}{{color "reset"}}` arnLabelPrintFmt = " %q: %q\n" arnPrintFmt = " %q\n" ) -type idpTemplateData struct { - IDP string -} -type roleTemplateData struct { - Role string -} - // WebSSOAuthentication Encapsulates the work of getting temporary IAM // credentials through Okta's Web SSO authentication with an Okta AWS Federation // Application. @@ -106,15 +101,6 @@ type idpAndRole struct { region string } -var stderrIsOutAskOpt = func(options *survey.AskOptions) error { - options.Stdio = terminal.Stdio{ - In: os.Stdin, - Out: os.Stderr, - Err: os.Stderr, - } - return nil -} - // NewWebSSOAuthentication New Web SSO Authentication constructor func NewWebSSOAuthentication(cfg *config.Config) (token *WebSSOAuthentication, err error) { token = &WebSSOAuthentication{ @@ -264,14 +250,7 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e if app.Settings.App.IdentityProviderARN != "" && idpARN == app.Settings.App.IdentityProviderARN { if !w.config.IsProcessCredentialsFormat() { - idpData := idpTemplateData{ - IDP: choiceLabel, - } - rich, _, err := core.RunTemplate(idpSelectedTemplate, idpData) - if err != nil { - return "", err - } - w.config.Logger.Warn(rich + "\n") + w.config.Logger.Warn(fmt.Sprintf(" %s %s\n", labelStyle.Render("IdP:"), valueStyle.Render(choiceLabel))) } return app.ID, nil @@ -281,11 +260,7 @@ func (w *WebSSOAuthentication) selectFedApp(apps []*okta.Application) (string, e idps[choiceLabel] = app } - prompt := &survey.Select{ - Message: chooseIDP, - Options: choices, - } - err = survey.AskOne(prompt, &selected, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + selected, err = picker.Pick(chooseIDP, choices) if err != nil { return "", fmt.Errorf(askIDPError, err) } @@ -467,9 +442,6 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol roleARN = roleARNs[0] } roleLabel := w.choiceFriendlyLabelRole(roleARN, configRoles) - roleData := roleTemplateData{ - Role: roleLabel, - } // reverse case when friendly role name alias is given as the input value // --aws-iam-role "OK S3 Read" @@ -483,11 +455,7 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol } if !w.config.IsProcessCredentialsFormat() { - rich, _, err := core.RunTemplate(roleSelectedTemplate, roleData) - if err != nil { - return "", err - } - w.config.Logger.Warn(rich + "\n") + w.config.Logger.Warn(fmt.Sprintf(" %s %s\n", labelStyle.Render("Role:"), valueStyle.Render(roleLabel))) } return roleARN, nil } @@ -500,12 +468,7 @@ func (w *WebSSOAuthentication) promptForRole(idp string, roleARNs []string) (rol labelsARNs[roleLabel] = arn } - prompt := &survey.Select{ - Message: chooseRole, - Options: promptRoles, - } - var selected string - err = survey.AskOne(prompt, &selected, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + selected, err := picker.Pick(chooseRole, promptRoles) if err != nil { return "", fmt.Errorf(askRoleError, err) } @@ -541,14 +504,7 @@ func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, er } idpLabel := w.choiceFriendlyLabelIDP(idpARN, idpARN, configIDPs) - idpData := idpTemplateData{ - IDP: idpLabel, - } - rich, _, err := core.RunTemplate(idpSelectedTemplate, idpData) - if err != nil { - return "", err - } - w.config.Logger.Warn(rich + "\n") + w.config.Logger.Warn(fmt.Sprintf(" %s %s\n", labelStyle.Render("IdP:"), valueStyle.Render(idpLabel))) return idpARN, nil } @@ -560,12 +516,7 @@ func (w *WebSSOAuthentication) promptForIDP(idpARNs []string) (idpARN string, er idpChoiceLabels[i] = idpLabel } - var idpChoice string - prompt := &survey.Select{ - Message: chooseIDP, - Options: idpChoiceLabels, - } - err = survey.AskOne(prompt, &idpChoice, survey.WithValidator(survey.Required), stderrIsOutAskOpt) + idpChoice, err := picker.Pick(chooseIDP, idpChoiceLabels) if err != nil { return idpARN, fmt.Errorf(askIDPError, err) } diff --git a/test/fixtures/vcr/TestChoiceFriendlyLabelIDP.yaml b/test/fixtures/vcr/TestChoiceFriendlyLabelIDP.yaml new file mode 100644 index 0000000..2797c38 --- /dev/null +++ b/test/fixtures/vcr/TestChoiceFriendlyLabelIDP.yaml @@ -0,0 +1,3 @@ +--- +version: 2 +interactions: [] diff --git a/test/fixtures/vcr/TestChoiceFriendlyLabelRole.yaml b/test/fixtures/vcr/TestChoiceFriendlyLabelRole.yaml new file mode 100644 index 0000000..2797c38 --- /dev/null +++ b/test/fixtures/vcr/TestChoiceFriendlyLabelRole.yaml @@ -0,0 +1,3 @@ +--- +version: 2 +interactions: []