Repository: gruntwork-io/terragrunt (language: go)

Issue:
Implement catalog command
**Implement catalog command with the following requirements**:

* See the list of modules
* Browse and search the modules
* View a single module





Discussion / hints:

Fix patch:
diff --git a/Makefile b/Makefile
index ae8bdd63e4..c25d681482 100644
--- a/Makefile
+++ b/Makefile
@@ -42,6 +42,6 @@ install-lint:
 	go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.54.2
 
 run-lint:
-	golangci-lint run -v ./...
+	golangci-lint run -v --timeout=5m ./...
 
 .PHONY: help fmtcheck fmt install-fmt-hook clean install-lint run-lint
diff --git a/cli/app.go b/cli/app.go
index 4d63627ba0..82fb271247 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -21,6 +21,7 @@ import (
 	"github.com/gruntwork-io/go-commons/env"
 	"github.com/gruntwork-io/terragrunt/cli/commands"
 	awsproviderpatch "github.com/gruntwork-io/terragrunt/cli/commands/aws-provider-patch"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog"
 	graphdependencies "github.com/gruntwork-io/terragrunt/cli/commands/graph-dependencies"
 	"github.com/gruntwork-io/terragrunt/cli/commands/hclfmt"
 	outputmodulegroups "github.com/gruntwork-io/terragrunt/cli/commands/output-module-groups"
@@ -77,6 +78,7 @@ func terragruntCommands(opts *options.TerragruntOptions) cli.Commands {
 		renderjson.NewCommand(opts),         // render-json
 		awsproviderpatch.NewCommand(opts),   // aws-provider-patch
 		outputmodulegroups.NewCommand(opts), // output-module-groups
+		catalog.NewCommand(opts),            // catalog
 		scaffold.NewCommand(opts),           // scaffold
 	}
 
diff --git a/cli/commands/catalog/action.go b/cli/commands/catalog/action.go
new file mode 100644
index 0000000000..b4bf41e829
--- /dev/null
+++ b/cli/commands/catalog/action.go
@@ -0,0 +1,32 @@
+package catalog
+
+import (
+	"context"
+
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui"
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/pkg/errors"
+)
+
+func Run(ctx context.Context, opts *options.TerragruntOptions, repoPath string) error {
+	log.SetLogger(opts.Logger.Logger)
+
+	repo, err := module.NewRepo(ctx, repoPath)
+	if err != nil {
+		return err
+	}
+	//nolint:errcheck
+	defer repo.RemoveTempData()
+
+	modules, err := repo.FindModules(ctx)
+	if err != nil {
+		return err
+	}
+	if len(modules) == 0 {
+		return errors.Errorf("specified repository %q does not contain modules", repoPath)
+	}
+
+	return tui.Run(ctx, modules, opts)
+}
diff --git a/cli/commands/catalog/command.go b/cli/commands/catalog/command.go
new file mode 100644
index 0000000000..4c1302ba57
--- /dev/null
+++ b/cli/commands/catalog/command.go
@@ -0,0 +1,27 @@
+package catalog
+
+import (
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/cli"
+)
+
+const (
+	CommandName = "catalog"
+)
+
+func NewCommand(opts *options.TerragruntOptions) *cli.Command {
+	return &cli.Command{
+		Name:                   CommandName,
+		DisallowUndefinedFlags: true,
+		Usage:                  "Launch the user interface for searching and managing your module catalog.",
+		Action: func(ctx *cli.Context) error {
+			var repoPath string
+
+			if val := ctx.Args().Get(0); val != "" {
+				repoPath = val
+			}
+
+			return Run(ctx, opts.OptionsFromContext(ctx), repoPath)
+		},
+	}
+}
diff --git a/cli/commands/catalog/module/module.go b/cli/commands/catalog/module/module.go
new file mode 100644
index 0000000000..98d8e3993d
--- /dev/null
+++ b/cli/commands/catalog/module/module.go
@@ -0,0 +1,205 @@
+package module
+
+import (
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/gruntwork-io/go-commons/collections"
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+)
+
+const (
+	mdHeader   = "#"
+	adocHeader = "="
+)
+
+var (
+	// `strings.EqualFold` is used (case insensitive) while comparing
+	acceptableReadmeFiles = []string{"README.md", "README.adoc"}
+
+	mdHeaderReg   = regexp.MustCompile(`(?m)^#{1}\s?([^#][\S\s]+)`)
+	adocHeaderReg = regexp.MustCompile(`(?m)^={1}\s?([^=][\S\s]+)`)
+
+	commentReg   = regexp.MustCompile(`<!--[\S\s]*?-->`)
+	adocImageReg = regexp.MustCompile(`image:[^\]]+]`)
+
+	terraformFileExts = []string{".tf"}
+	ignoreFiles       = []string{"terraform-cloud-enterprise-private-module-registry-placeholder.tf"}
+
+	defaultDescription = "(no description found)"
+)
+
+type Modules []*Module
+
+type Module struct {
+	repoPath    string
+	moduleDir   string
+	url         string
+	title       string
+	description string
+	readme      string
+}
+
+// NewModule returns a module instance if the given `moduleDir` path contains a Terraform module, otherwise returns nil.
+func NewModule(repo *Repo, moduleDir string) (*Module, error) {
+	module := &Module{
+		repoPath:    repo.path,
+		moduleDir:   moduleDir,
+		title:       filepath.Base(moduleDir),
+		description: defaultDescription,
+	}
+
+	if ok, err := module.isValid(); !ok || err != nil {
+		return nil, err
+	}
+
+	log.Debugf("Found module in directory %q", moduleDir)
+
+	moduleURL, err := repo.moduleURL(moduleDir)
+	if err != nil {
+		return nil, err
+	}
+	module.url = moduleURL
+
+	if err := module.parseReadme(); err != nil {
+		return nil, err
+	}
+
+	return module, nil
+}
+
+// Title implements /github.com/charmbracelet/bubbles.list.DefaultItem.Title
+func (module *Module) Title() string {
+	return module.title
+}
+
+// Description implements /github.com/charmbracelet/bubbles.list.DefaultItem.Description
+func (module *Module) Description() string {
+	return module.description
+}
+
+func (module *Module) Readme() string {
+	return module.readme
+}
+
+// FilterValue implements /github.com/charmbracelet/bubbles.list.Item.FilterValue
+func (module *Module) FilterValue() string {
+	return module.title
+}
+
+func (module *Module) URL() string {
+	return module.url
+}
+
+func (module *Module) Path() string {
+	return fmt.Sprintf("%s//%s", module.repoPath, module.moduleDir)
+
+}
+
+func (module *Module) isValid() (bool, error) {
+	files, err := os.ReadDir(filepath.Join(module.repoPath, module.moduleDir))
+	if err != nil {
+		return false, errors.WithStackTrace(err)
+	}
+
+	for _, file := range files {
+		if file.IsDir() {
+			continue
+		}
+
+		if collections.ListContainsElement(ignoreFiles, file.Name()) {
+			continue
+		}
+
+		ext := filepath.Ext(file.Name())
+		if collections.ListContainsElement(terraformFileExts, ext) {
+			return true, nil
+		}
+	}
+
+	return false, nil
+}
+
+func (module *Module) parseReadme() error {
+	var readmePath string
+
+	modulePath := filepath.Join(module.repoPath, module.moduleDir)
+
+	files, err := os.ReadDir(modulePath)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	for _, file := range files {
+		if file.IsDir() {
+			continue
+		}
+
+		for _, readmeFile := range acceptableReadmeFiles {
+			if strings.EqualFold(readmeFile, file.Name()) {
+				readmePath = filepath.Join(modulePath, file.Name())
+				break
+			}
+		}
+
+		// `md` files have priority over `adoc` files
+		if strings.EqualFold(filepath.Ext(readmePath), ".md") {
+			break
+		}
+	}
+
+	if readmePath == "" {
+		return nil
+	}
+
+	readmeByte, err := os.ReadFile(readmePath)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+	module.readme = string(readmeByte)
+
+	var (
+		reg       = mdHeaderReg
+		docHeader = mdHeader
+	)
+
+	if strings.HasSuffix(readmePath, ".adoc") {
+		reg = adocHeaderReg
+		docHeader = adocHeader
+	}
+
+	if match := reg.FindStringSubmatch(module.readme); len(match) > 0 {
+		header := match[1]
+
+		// remove comments
+		header = commentReg.ReplaceAllString(header, "")
+		// remove adoc images
+		header = adocImageReg.ReplaceAllString(header, "")
+
+		lines := strings.Split(header, "\n")
+		module.title = strings.TrimSpace(lines[0])
+
+		var descriptionLines []string
+
+		if len(lines) > 1 {
+			for _, line := range lines[1:] {
+				line = strings.TrimSpace(line)
+
+				// another header begins
+				if strings.HasPrefix(line, docHeader) {
+					break
+				}
+
+				descriptionLines = append(descriptionLines, line)
+			}
+		}
+
+		module.description = strings.TrimSpace(strings.Join(descriptionLines, " "))
+	}
+
+	return nil
+}
diff --git a/cli/commands/catalog/module/repo.go b/cli/commands/catalog/module/repo.go
new file mode 100644
index 0000000000..967c462691
--- /dev/null
+++ b/cli/commands/catalog/module/repo.go
@@ -0,0 +1,251 @@
+package module
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strings"
+
+	"github.com/gitsight/go-vcsurl"
+	"github.com/gruntwork-io/go-commons/errors"
+	"github.com/gruntwork-io/go-commons/files"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+	"github.com/gruntwork-io/terragrunt/terraform"
+	"github.com/hashicorp/go-getter"
+	"gopkg.in/ini.v1"
+)
+
+const (
+	githubHost    = "github.com"
+	gitlabHost    = "gitlab.com"
+	azuredevHost  = "dev.azure.com"
+	bitbucketHost = "bitbucket.org"
+
+	tempDirPattern = "catalog-*"
+)
+
+var (
+	gitHeadBranchName = regexp.MustCompile(`^.*?([^/]+)$`)
+
+	modulesPaths = []string{"modules"}
+)
+
+type Repo struct {
+	path    string
+	tempDir string
+
+	remoteURL  string
+	branchName string
+}
+
+func NewRepo(ctx context.Context, path string) (*Repo, error) {
+	repo := &Repo{
+		path: path,
+	}
+
+	if err := repo.clone(ctx); err != nil {
+		return nil, err
+	}
+
+	if err := repo.parseRemoteURL(); err != nil {
+		return nil, err
+	}
+
+	if err := repo.parseBranchName(); err != nil {
+		return nil, err
+	}
+
+	return repo, nil
+}
+
+func (repo *Repo) RemoveTempData() error {
+	return os.RemoveAll(repo.tempDir)
+}
+
+// FindModules clones the repository if `repoPath` is a URL, searches for Terragrunt modules, indexes their README.* files, and returns module instances.
+func (repo *Repo) FindModules(ctx context.Context) (Modules, error) {
+	var modules Modules
+
+	// check if root repo path is a module dir
+	if module, err := NewModule(repo, ""); err != nil {
+		return nil, err
+	} else if module != nil {
+		modules = append(modules, module)
+	}
+
+	for _, modulesPath := range modulesPaths {
+		modulesPath = filepath.Join(repo.path, modulesPath)
+
+		if !files.FileExists(modulesPath) {
+			continue
+		}
+
+		err := filepath.Walk(modulesPath,
+			func(dir string, remote os.FileInfo, err error) error {
+				if err != nil {
+					return err
+				}
+				if !remote.IsDir() {
+					return nil
+				}
+
+				moduleDir, err := filepath.Rel(repo.path, dir)
+				if err != nil {
+					return errors.WithStackTrace(err)
+				}
+
+				if module, err := NewModule(repo, moduleDir); err != nil {
+					return err
+				} else if module != nil {
+					modules = append(modules, module)
+				}
+
+				return nil
+			})
+		if err != nil {
+			return nil, err
+		}
+
+	}
+
+	return modules, nil
+}
+
+// moduleURL returns the URL of the module in this repository. `moduleDir` is the path from the repository root.
+func (repo *Repo) moduleURL(moduleDir string) (string, error) {
+	if repo.remoteURL == "" {
+		return filepath.Join(repo.path, moduleDir), nil
+	}
+
+	remote, err := vcsurl.Parse(repo.remoteURL)
+	if err != nil {
+		return "", errors.WithStackTrace(err)
+	}
+
+	switch remote.Host {
+	case githubHost:
+		return fmt.Sprintf("https://%s/%s/tree/%s/%s", remote.Host, remote.FullName, repo.branchName, moduleDir), nil
+	case gitlabHost:
+		return fmt.Sprintf("https://%s/%s/-/tree/%s/%s", remote.Host, remote.FullName, repo.branchName, moduleDir), nil
+	case bitbucketHost:
+		return fmt.Sprintf("https://%s/%s/browse/%s?at=%s", remote.Host, remote.FullName, moduleDir, repo.branchName), nil
+	case azuredevHost:
+		return fmt.Sprintf("https://%s/_git/%s?path=%s&version=GB%s", remote.Host, remote.FullName, moduleDir, repo.branchName), nil
+	default:
+		return "", errors.Errorf("hosting: %q is not supported yet", remote.Host)
+	}
+}
+
+// clone clones the repository to a temporary directory if the repoPath is URL
+func (repo *Repo) clone(ctx context.Context) error {
+	if repo.path == "" {
+		currentDir, err := os.Getwd()
+		if err != nil {
+			return errors.WithStackTrace(err)
+		}
+
+		repo.path = currentDir
+	}
+
+	if files.IsDir(repo.path) {
+		if !filepath.IsAbs(repo.path) {
+			absRepoPath, err := filepath.Abs(repo.path)
+			if err != nil {
+				return errors.WithStackTrace(err)
+			}
+
+			log.Debugf("Converting relative path %q to absolute %q", repo.path, absRepoPath)
+
+			repo.path = absRepoPath
+		}
+
+		return nil
+	}
+
+	tempDir, err := os.MkdirTemp("", tempDirPattern)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+	repo.tempDir = tempDir
+
+	repoURL, err := terraform.ToSourceUrl(repo.path, tempDir)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	log.Infof("Cloning repository %q to temprory directory %q", repoURL, tempDir)
+
+	// if the URL has `http(s)` schema, go-getter does not clone repo.
+	if strings.HasPrefix(repoURL.Scheme, "http") {
+		repoURL.Scheme = ""
+	}
+
+	// if no repo directory is specified, `go-getter` returns the error "git exited with 128: fatal: not a git repository (or any of the parent directories"
+	if !strings.Contains(repoURL.RequestURI(), "//") {
+		repoURL.Path += "//."
+	}
+
+	if err := getter.GetAny(tempDir, strings.Trim(repoURL.String(), "/"), getter.WithContext(ctx)); err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	repo.path = tempDir
+	return nil
+}
+
+// parseRemoteURL reads the git config `.git/config` and parses the first URL of the remote URLs, the remote name "origin" has the highest priority.
+func (repo *Repo) parseRemoteURL() error {
+	gitConfigPath := filepath.Join(repo.path, ".git", "config")
+
+	if !files.FileExists(gitConfigPath) {
+		return errors.Errorf("the specified path %q is not a git repository", repo.path)
+	}
+
+	log.Debugf("Parsing git config %q", gitConfigPath)
+
+	inidata, err := ini.Load(gitConfigPath)
+	if err != nil {
+		return errors.WithStackTrace(err)
+	}
+
+	var sectionName string
+	for _, name := range inidata.SectionStrings() {
+		if !strings.HasPrefix(name, "remote") {
+			continue
+		}
+		sectionName = name
+
+		if sectionName == `remote "origin"` {
+			break
+		}
+	}
+
+	// no git remotes found
+	if sectionName == "" {
+		return nil
+	}
+
+	repo.remoteURL = inidata.Section(sectionName).Key("url").String()
+	log.Debugf("Remote url: %q for repo: %q", repo.remoteURL, repo.path)
+
+	return nil
+}
+
+// parseBranchName reads `.git/HEAD` file and parses a branch name.
+func (repo *Repo) parseBranchName() error {
+	gitHeadFile := filepath.Join(repo.path, ".git", "HEAD")
+
+	data, err := files.ReadFileAsString(gitHeadFile)
+	if err != nil {
+		return errors.Errorf("the specified path %q is not a git repository", repo.path)
+	}
+
+	if match := gitHeadBranchName.FindStringSubmatch(data); len(match) > 0 {
+		repo.branchName = strings.TrimSpace(match[1])
+		return nil
+	}
+
+	return errors.Errorf("could not get branch name for repo %q", repo.path)
+}
diff --git a/cli/commands/catalog/tui/command/scaffold.go b/cli/commands/catalog/tui/command/scaffold.go
new file mode 100644
index 0000000000..8c90abea36
--- /dev/null
+++ b/cli/commands/catalog/tui/command/scaffold.go
@@ -0,0 +1,36 @@
+package command
+
+import (
+	"io"
+
+	"github.com/gruntwork-io/terragrunt/cli/commands/scaffold"
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/gruntwork-io/terragrunt/pkg/log"
+)
+
+type Scaffold struct {
+	moduleDir         string
+	terragruntOptions *options.TerragruntOptions
+}
+
+func NewScaffold(moduleDir string, opts *options.TerragruntOptions) *Scaffold {
+	return &Scaffold{
+		moduleDir:         moduleDir,
+		terragruntOptions: opts,
+	}
+}
+
+func (cmd *Scaffold) Run() error {
+	log.Infof("Run Scaffold for the module: %q", cmd.moduleDir)
+
+	return scaffold.Run(cmd.terragruntOptions, cmd.moduleDir, "")
+}
+
+func (cmd *Scaffold) SetStdin(io.Reader) {
+}
+
+func (cmd *Scaffold) SetStdout(io.Writer) {
+}
+
+func (cmd *Scaffold) SetStderr(io.Writer) {
+}
diff --git a/cli/commands/catalog/tui/models/list/delegate.go b/cli/commands/catalog/tui/models/list/delegate.go
new file mode 100644
index 0000000000..1c499742e4
--- /dev/null
+++ b/cli/commands/catalog/tui/models/list/delegate.go
@@ -0,0 +1,39 @@
+package list
+
+import (
+	"github.com/charmbracelet/bubbles/list"
+	"github.com/charmbracelet/lipgloss"
+)
+
+const (
+	selectedTitleForegroundColorDark       = "#63C5DA"
+	selectedTitleBorderForegroundColorDark = "#63C5DA"
+
+	selectedDescForegroundColorDark       = "#59788E"
+	selectedDescBorderForegroundColorDark = "#63C5DA"
+)
+
+type DefaultDelegate struct {
+	list.DefaultDelegate
+}
+
+type Delegate struct {
+	DefaultDelegate
+	*DelegateKeyMap
+}
+
+func NewDelegate() *Delegate {
+	defaultDelegate := list.NewDefaultDelegate()
+	defaultDelegate.Styles.SelectedTitle.
+		Foreground(lipgloss.AdaptiveColor{Dark: selectedTitleForegroundColorDark}).
+		BorderForeground(lipgloss.AdaptiveColor{Dark: selectedTitleBorderForegroundColorDark})
+
+	defaultDelegate.Styles.SelectedDesc = defaultDelegate.Styles.SelectedTitle.Copy().
+		Foreground(lipgloss.AdaptiveColor{Dark: selectedDescForegroundColorDark}).
+		BorderForeground(lipgloss.AdaptiveColor{Dark: selectedDescBorderForegroundColorDark})
+
+	return &Delegate{
+		DefaultDelegate: DefaultDelegate{defaultDelegate},
+		DelegateKeyMap:  NewDelegateKeyMap(),
+	}
+}
diff --git a/cli/commands/catalog/tui/models/list/delegate_keys.go b/cli/commands/catalog/tui/models/list/delegate_keys.go
new file mode 100644
index 0000000000..c19ba49209
--- /dev/null
+++ b/cli/commands/catalog/tui/models/list/delegate_keys.go
@@ -0,0 +1,45 @@
+package list
+
+import "github.com/charmbracelet/bubbles/key"
+
+// DelegateKeyMap defines keybindings. It satisfies to the help.DelegateKeyMap interface, which
+// is used to render the menu.
+type DelegateKeyMap struct {
+	// Select module
+	Choose key.Binding
+
+	// Run Scaffold command
+	Scaffold key.Binding
+}
+
+// NewDelegateKeyMap returns a set of keybindings.
+func NewDelegateKeyMap() *DelegateKeyMap {
+	return &DelegateKeyMap{
+		Choose: key.NewBinding(
+			key.WithKeys("enter", "ctrl-j"),
+			key.WithHelp("enter/ctrl-j", "choose"),
+		),
+		Scaffold: key.NewBinding(
+			key.WithKeys("S", "s"),
+			key.WithHelp("S", "Scaffold"),
+		),
+	}
+}
+
+// Additional short help entries. This satisfies the help.KeyMap interface and
+// is entirely optional.
+func (d DelegateKeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		d.Choose, d.Scaffold,
+	}
+}
+
+// Additional full help entries. This satisfies the help.KeyMap interface and
+// is entirely optional.
+func (d DelegateKeyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{
+			d.Choose, d.Scaffold,
+		},
+	}
+}
diff --git a/cli/commands/catalog/tui/models/list/model.go b/cli/commands/catalog/tui/models/list/model.go
new file mode 100644
index 0000000000..6ca5c8956a
--- /dev/null
+++ b/cli/commands/catalog/tui/models/list/model.go
@@ -0,0 +1,98 @@
+package list
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui/models/page"
+	"github.com/gruntwork-io/terragrunt/options"
+)
+
+const (
+	title = "List of Modules"
+
+	titleForegroundColor = "#A8ACB1"
+	titleBackgroundColor = "#1D252F"
+)
+
+type Model struct {
+	*list.Model
+	delegate          *Delegate
+	quitFn            func(error)
+	terragruntOptions *options.TerragruntOptions
+}
+
+func NewModel(modules module.Modules, quitFn func(error), opts *options.TerragruntOptions) *Model {
+	var items []list.Item
+	for _, module := range modules {
+		items = append(items, module)
+	}
+
+	delegate := NewDelegate()
+
+	model := list.New(items, delegate, 0, 0)
+	model.KeyMap = NewKeyMap()
+	model.SetFilteringEnabled(true)
+	model.Title = title
+	model.Styles.Title = lipgloss.NewStyle().
+		Foreground(lipgloss.Color(titleForegroundColor)).
+		Background(lipgloss.Color(titleBackgroundColor)).
+		Padding(0, 1)
+
+	return &Model{
+		Model:             &model,
+		delegate:          delegate,
+		quitFn:            quitFn,
+		terragruntOptions: opts,
+	}
+}
+
+// Init implements bubbletea.Model.Init
+func (model Model) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements bubbletea.Model.Update
+func (model Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		topPadding := 1
+		rightPadding := 2
+		h, v := lipgloss.NewStyle().Padding(topPadding, rightPadding).GetFrameSize()
+		model.SetSize(msg.Width-h, msg.Height-v)
+
+	case tea.KeyMsg:
+		// Don't match any of the keys below if we're actively filtering.
+		if model.FilterState() == list.Filtering {
+			break
+		}
+
+		if key.Matches(msg, model.delegate.Choose, model.delegate.Scaffold) {
+			if module, ok := model.SelectedItem().(*module.Module); ok {
+				pageModel, err := page.NewModel(module, model.Width(), model.Height(), model, model.quitFn, model.terragruntOptions)
+				if err != nil {
+					model.quitFn(err)
+				}
+
+				if key.Matches(msg, model.delegate.Scaffold) {
+					if btn := pageModel.Buttons.GetByName(page.ScaffoldButtonName); btn != nil {
+						cmd := btn.Action(msg)
+						return model, cmd
+					}
+				}
+
+				return pageModel, nil
+			}
+		}
+	}
+
+	newModel, cmd := model.Model.Update(msg)
+	model.Model = &newModel
+	cmds = append(cmds, cmd)
+
+	return model, tea.Batch(cmds...)
+}
diff --git a/cli/commands/catalog/tui/models/list/model_keys.go b/cli/commands/catalog/tui/models/list/model_keys.go
new file mode 100644
index 0000000000..849d0b92cc
--- /dev/null
+++ b/cli/commands/catalog/tui/models/list/model_keys.go
@@ -0,0 +1,72 @@
+package list
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+)
+
+// NewKeyMap returns a set of keybindings.
+func NewKeyMap() list.KeyMap {
+	return list.KeyMap{
+		// Browsing.
+		CursorUp: key.NewBinding(
+			key.WithKeys("up", "ctrl+p"),
+			key.WithHelp("↑/ctrl+p", "move up"),
+		),
+		CursorDown: key.NewBinding(
+			key.WithKeys("down", "ctrl+n"),
+			key.WithHelp("↓/ctrl+n", "move down"),
+		),
+		PrevPage: key.NewBinding(
+			key.WithKeys("left", "pgup", "alt+v"),
+			key.WithHelp("←/pgup/alt+v", "prev page"),
+		),
+		NextPage: key.NewBinding(
+			key.WithKeys("right", "pgdown", "ctrl+v"),
+			key.WithHelp("→/pgdn/ctrl+v", "next page"),
+		),
+		GoToStart: key.NewBinding(
+			key.WithKeys("home", "ctrl+a"),
+			key.WithHelp("home/ctrl+a", "go to start"),
+		),
+		GoToEnd: key.NewBinding(
+			key.WithKeys("end", "ctrl+e"),
+			key.WithHelp("end/ctrl+e", "go to end"),
+		),
+		Filter: key.NewBinding(
+			key.WithKeys("/"),
+			key.WithHelp("/", "search"),
+		),
+		ClearFilter: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "clear filter"),
+		),
+
+		// Filtering.
+		CancelWhileFiltering: key.NewBinding(
+			key.WithKeys("esc"),
+			key.WithHelp("esc", "cancel"),
+		),
+		AcceptWhileFiltering: key.NewBinding(
+			key.WithKeys("enter", "tab", "shift+tab", "ctrl+k", "up", "ctrl+j", "down"),
+			key.WithHelp("enter", "apply filter"),
+		),
+
+		// Toggle help.
+		ShowFullHelp: key.NewBinding(
+			key.WithKeys("?"),
+			key.WithHelp("?", "more"),
+		),
+		CloseFullHelp: key.NewBinding(
+			key.WithKeys("?"),
+			key.WithHelp("?", "close help"),
+		),
+
+		// Quitting.
+		Quit: key.NewBinding(
+			key.WithKeys("q", "esc"),
+			key.WithHelp("q", "quit"),
+		),
+		ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
+	}
+}
diff --git a/cli/commands/catalog/tui/models/page/button.go b/cli/commands/catalog/tui/models/page/button.go
new file mode 100644
index 0000000000..87765492c3
--- /dev/null
+++ b/cli/commands/catalog/tui/models/page/button.go
@@ -0,0 +1,99 @@
+package page
+
+import (
+	"fmt"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/gruntwork-io/go-commons/collections"
+)
+
+const (
+	defaultButtonNameFmt = "[ %s ]"
+)
+
+var (
+	defaultButtonFocusedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
+	defatulButtonBlurredStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
+)
+
+type ButtonActionFunc func(msg tea.Msg) tea.Cmd
+
+type Button struct {
+	focusedStyle lipgloss.Style
+	blurredStyle lipgloss.Style
+	index        int
+	selected     bool
+	nameFmt      string
+	name         string
+	action       ButtonActionFunc
+}
+
+func NewButton(name string, action ButtonActionFunc) *Button {
+	return &Button{
+		name:         name,
+		action:       action,
+		nameFmt:      defaultButtonNameFmt,
+		focusedStyle: defaultButtonFocusedStyle,
+		blurredStyle: defatulButtonBlurredStyle,
+	}
+}
+
+func (btn *Button) Action(msg tea.Msg) tea.Cmd {
+	return btn.action(msg)
+}
+
+type Buttons []*Button
+
+func NewButtons(btns ...*Button) Buttons {
+	for i, btn := range btns {
+		if btn.index == 0 {
+			btn.index = i + 1
+		}
+	}
+	return btns
+}
+
+func (btns Buttons) Len() int {
+	return len(btns)
+}
+
+func (btns Buttons) Focus(index ...int) Buttons {
+	for i, btn := range btns {
+		btn.selected = collections.ListContainsElement(index, i+1)
+	}
+	return btns
+}
+
+func (btns Buttons) Get(index ...int) *Button {
+	for i, btn := range btns {
+		if collections.ListContainsElement(index, i+1) {
+			return btn
+		}
+	}
+	return nil
+}
+
+func (btns Buttons) GetByName(name string) *Button {
+	for _, btn := range btns {
+		if btn.name == name {
+			return btn
+		}
+	}
+	return nil
+}
+
+func (btns Buttons) View() string {
+	names := make([]string, btns.Len())
+
+	for i, btn := range btns {
+		if btn.selected {
+			names[i] = fmt.Sprintf(btn.nameFmt, btn.focusedStyle.Render(btn.name))
+		} else {
+			names[i] = fmt.Sprintf(btn.nameFmt, btn.blurredStyle.Render(btn.name))
+		}
+	}
+
+	leftPadding := 2
+	return lipgloss.NewStyle().Padding(0, 0, 0, leftPadding).Render(names...)
+}
diff --git a/cli/commands/catalog/tui/models/page/key.go b/cli/commands/catalog/tui/models/page/key.go
new file mode 100644
index 0000000000..26a8452a41
--- /dev/null
+++ b/cli/commands/catalog/tui/models/page/key.go
@@ -0,0 +1,117 @@
+package page
+
+import (
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// KeyMap defines a set of keybindings. To work for help it must satisfy
+// key.Map. It could also very easily be a map[string]key.Binding.
+type KeyMap struct {
+	viewport.KeyMap
+	help help.Model
+
+	// Button navigation
+	Navigation key.Binding
+
+	// Select button
+	Choose key.Binding
+
+	// Run Scaffold command
+	Scaffold key.Binding
+
+	// Help toggle keybindings.
+	Help key.Binding
+
+	// The quit keybinding. This won't be caught when filtering.
+	Quit key.Binding
+
+	// The quit-no-matter-what keybinding. This will be caught when filtering.
+	ForceQuit key.Binding
+}
+
+// ShortHelp returns keybindings to be shown in the mini help view. It's part
+// of the key.Map interface.
+func (keys KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{keys.Up, keys.Down, keys.Navigation, keys.Choose, keys.Scaffold, keys.Help, keys.Quit}
+}
+
+// FullHelp returns keybindings for the expanded help view. It's part of the
+// key.Map interface.
+func (keys KeyMap) FullHelp() [][]key.Binding {
+	return [][]key.Binding{
+		{keys.Up, keys.Down, keys.PageDown, keys.PageUp}, // first column
+		{keys.Navigation, keys.Choose, keys.Scaffold},    // second column
+		{keys.Help, keys.Quit, keys.ForceQuit},           // third column
+	}
+}
+
+func (keys *KeyMap) Update(msg tea.Msg) tea.Cmd {
+	var cmds []tea.Cmd
+
+	if msg, ok := msg.(tea.KeyMsg); ok && key.Matches(msg, keys.Help) {
+		keys.help.ShowAll = !keys.help.ShowAll
+	}
+
+	return tea.Batch(cmds...)
+}
+
+func (keys *KeyMap) View() string {
+	topPadding := 2
+	leftPadding := 2
+	return lipgloss.NewStyle().Padding(topPadding, 0, 0, leftPadding).Render(keys.help.View(keys))
+}
+
+func newKeyMap() KeyMap {
+	return KeyMap{
+		help: help.New(),
+		KeyMap: viewport.KeyMap{
+			HalfPageUp: key.NewBinding(
+				key.WithDisabled(),
+			),
+			HalfPageDown: key.NewBinding(
+				key.WithDisabled(),
+			),
+			Up: key.NewBinding(
+				key.WithKeys("up", "ctrl+p"),
+				key.WithHelp("↑/ctrl+p", "move up"),
+			),
+			Down: key.NewBinding(
+				key.WithKeys("down", "ctrl+n"),
+				key.WithHelp("↓/ctrl+n", "move down"),
+			),
+			PageDown: key.NewBinding(
+				key.WithKeys("right", "pgdown", "ctrl+v"),
+				key.WithHelp("→/pgdn/ctrl+v", "page down"),
+			),
+			PageUp: key.NewBinding(
+				key.WithKeys("left", "pgup", "alt+v"),
+				key.WithHelp("←/pgup/alt+v", "page up"),
+			),
+		},
+		Navigation: key.NewBinding(
+			key.WithKeys("tab"),
+			key.WithHelp("tab", "navigation"),
+		),
+		Choose: key.NewBinding(
+			key.WithKeys("enter", "ctrl-j"),
+			key.WithHelp("enter/ctrl-j", "choose"),
+		),
+		Scaffold: key.NewBinding(
+			key.WithKeys("S", "s"),
+			key.WithHelp("S", "Scaffold"),
+		),
+		Help: key.NewBinding(
+			key.WithKeys("?"),
+			key.WithHelp("?", "toggle help"),
+		),
+		Quit: key.NewBinding(
+			key.WithKeys("q", "esc"),
+			key.WithHelp("q", "back to list"),
+		),
+		ForceQuit: key.NewBinding(key.WithKeys("ctrl+c")),
+	}
+}
diff --git a/cli/commands/catalog/tui/models/page/model.go b/cli/commands/catalog/tui/models/page/model.go
new file mode 100644
index 0000000000..8d66b11241
--- /dev/null
+++ b/cli/commands/catalog/tui/models/page/model.go
@@ -0,0 +1,174 @@
+package page
+
+// An example program demonstrating the pager component from the Bubbles
+// component library.
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui/command"
+	"github.com/gruntwork-io/terragrunt/options"
+	"github.com/pkg/browser"
+)
+
+const (
+	defaultFocusIndex = 1
+
+	ScaffoldButtonName      = "Scaffold"
+	ViewInBrowserButtonName = "View Source in Browser"
+)
+
+var (
+	infoPositionStyle = lipgloss.NewStyle().Padding(0, 1).BorderStyle(lipgloss.HiddenBorder())
+	infoLineStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("#1D252"))
+)
+
+type Model struct {
+	Buttons Buttons
+
+	viewport      *viewport.Model
+	previousModel tea.Model
+
+	height int
+
+	keys       KeyMap
+	focusIndex int
+}
+
+func NewModel(module *module.Module, width, height int, previousModel tea.Model, quitFn func(error), opts *options.TerragruntOptions) (*Model, error) {
+	renderer, err := glamour.NewTermRenderer(
+		glamour.WithAutoStyle(),
+		glamour.WithWordWrap(width),
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	content, err := renderer.Render(module.Readme())
+	if err != nil {
+		return nil, err
+	}
+
+	keys := newKeyMap()
+
+	viewport := viewport.New(width, height)
+	viewport.SetContent(content)
+	viewport.KeyMap = keys.KeyMap
+
+	return &Model{
+		viewport:      &viewport,
+		height:        height,
+		keys:          keys,
+		previousModel: previousModel,
+		focusIndex:    defaultFocusIndex,
+		Buttons: NewButtons(
+			NewButton(ScaffoldButtonName, func(msg tea.Msg) tea.Cmd {
+				quitFn := func(err error) tea.Msg {
+					quitFn(err)
+					return nil
+				}
+				return tea.Exec(command.NewScaffold(module.Path(), opts), quitFn)
+			}),
+			NewButton(ViewInBrowserButtonName, func(msg tea.Msg) tea.Cmd {
+				if err := browser.OpenURL(module.URL()); err != nil {
+					quitFn(err)
+				}
+				return nil
+			}),
+		).Focus(defaultFocusIndex),
+	}, nil
+}
+
+// Init implements bubbletea.Model.Init
+func (model Model) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements bubbletea.Model.Update
+func (model Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var (
+		cmd  tea.Cmd
+		cmds []tea.Cmd
+	)
+
+	cmd = model.keys.Update(msg)
+	cmds = append(cmds, cmd)
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, model.keys.Navigation):
+			model.focusIndex++
+
+			maxIndex := model.Buttons.Len()
+
+			if model.focusIndex > maxIndex {
+				model.focusIndex = 1
+			} else if model.focusIndex < 0 {
+				model.focusIndex = maxIndex
+			}
+
+			model.Buttons.Focus(model.focusIndex)
+
+			return model, tea.Batch(cmds...)
+
+		case key.Matches(msg, model.keys.Choose):
+			if btn := model.Buttons.Get(model.focusIndex); btn != nil {
+				cmd := btn.action(msg)
+				return model, cmd
+			}
+
+		case key.Matches(msg, model.keys.Scaffold):
+			if btn := model.Buttons.GetByName(ScaffoldButtonName); btn != nil {
+				cmd := btn.action(msg)
+				return model, cmd
+			}
+
+		case key.Matches(msg, model.keys.Quit):
+			return model.previousModel, nil
+		case key.Matches(msg, model.keys.ForceQuit):
+			return model, tea.Quit
+		}
+
+	case tea.WindowSizeMsg:
+		model.height = msg.Height
+		model.viewport.Width = msg.Width
+		model.viewport.Height = msg.Height - lipgloss.Height(model.footerView())
+	}
+
+	var viewport viewport.Model
+	viewport, cmd = model.viewport.Update(msg)
+
+	model.viewport = &viewport
+	cmds = append(cmds, cmd)
+
+	return model, tea.Batch(cmds...)
+}
+
+// View implements bubbletea.Model.View
+func (model Model) View() string {
+	footer := model.footerView()
+	footerHeight := lipgloss.Height(model.footerView())
+	model.viewport.Height = model.height - footerHeight
+
+	return lipgloss.JoinVertical(lipgloss.Left, model.viewport.View(), footer)
+}
+
+func (model Model) footerView() string {
+	var percent float64 = 100
+	info := infoPositionStyle.Render(fmt.Sprintf("%2.f%%", model.viewport.ScrollPercent()*percent))
+
+	line := strings.Repeat("─", max(0, model.viewport.Width-lipgloss.Width(info)))
+	line = infoLineStyle.Render(line)
+
+	info = lipgloss.JoinHorizontal(lipgloss.Center, line, info)
+
+	return lipgloss.JoinVertical(lipgloss.Left, info, model.Buttons.View(), model.keys.View())
+}
diff --git a/cli/commands/catalog/tui/models/page/util.go b/cli/commands/catalog/tui/models/page/util.go
new file mode 100644
index 0000000000..fed4ded49d
--- /dev/null
+++ b/cli/commands/catalog/tui/models/page/util.go
@@ -0,0 +1,8 @@
+package page
+
+func max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}
diff --git a/cli/commands/catalog/tui/tui.go b/cli/commands/catalog/tui/tui.go
new file mode 100644
index 0000000000..5a2064dc9b
--- /dev/null
+++ b/cli/commands/catalog/tui/tui.go
@@ -0,0 +1,31 @@
+package tui
+
+import (
+	"context"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/module"
+	"github.com/gruntwork-io/terragrunt/cli/commands/catalog/tui/models/list"
+	"github.com/gruntwork-io/terragrunt/options"
+)
+
+func Run(ctx context.Context, modules module.Modules, opts *options.TerragruntOptions) error {
+	ctx, cancel := context.WithCancelCause(ctx)
+	quitFn := func(err error) {
+		go cancel(err)
+	}
+
+	list := list.NewModel(modules, quitFn, opts)
+
+	if _, err := tea.NewProgram(list, tea.WithAltScreen(), tea.WithContext(ctx)).Run(); err != nil {
+		if err := context.Cause(ctx); err == context.Canceled {
+			return nil
+		} else if err != nil {
+			return err
+		}
+
+		return err
+	}
+
+	return nil
+}
diff --git a/cli/commands/scaffold/action.go b/cli/commands/scaffold/action.go
index a743b5a1ac..991842ec7e 100644
--- a/cli/commands/scaffold/action.go
+++ b/cli/commands/scaffold/action.go
@@ -7,8 +7,6 @@ import (
 	"regexp"
 	"strings"
 
-	"github.com/gruntwork-io/terragrunt/pkg/cli"
-
 	"github.com/gruntwork-io/terragrunt/config"
 	"github.com/gruntwork-io/terragrunt/shell"
 
@@ -85,10 +83,8 @@ inputs = {
 
 var moduleUrlRegex = regexp.MustCompile(moduleUrlPattern)
 
-func Run(ctx *cli.Context, opts *options.TerragruntOptions) error {
+func Run(opts *options.TerragruntOptions, moduleUrl, templateUrl string) error {
 	// download remote repo to local
-	var moduleUrl = ""
-	var templateUrl = ""
 	var dirsToClean []string
 	// clean all temp dirs
 	defer func() {
@@ -107,14 +103,6 @@ func Run(ctx *cli.Context, opts *options.TerragruntOptions) error {
 		opts.Logger.Warnf("The working directory %s is not empty.", opts.WorkingDir)
 	}
 
-	if val := ctx.Args().Get(0); val != "" {
-		moduleUrl = val
-	}
-
-	if val := ctx.Args().Get(1); val != "" {
-		templateUrl = val
-	}
-
 	if moduleUrl == "" {
 		return errors.WithStackTrace(NoModuleUrlPassed{})
 	}
diff --git a/cli/commands/scaffold/command.go b/cli/commands/scaffold/command.go
index fd797ac232..1acc800390 100644
--- a/cli/commands/scaffold/command.go
+++ b/cli/commands/scaffold/command.go
@@ -32,6 +32,18 @@ func NewCommand(opts *options.TerragruntOptions) *cli.Command {
 		Usage:                  "Scaffold a new Terragrunt module.",
 		DisallowUndefinedFlags: true,
 		Flags:                  NewFlags(opts).Sort(),
-		Action:                 func(ctx *cli.Context) error { return Run(ctx, opts.OptionsFromContext(ctx)) },
+		Action: func(ctx *cli.Context) error {
+			var moduleUrl, templateUrl string
+
+			if val := ctx.Args().Get(0); val != "" {
+				moduleUrl = val
+			}
+
+			if val := ctx.Args().Get(1); val != "" {
+				templateUrl = val
+			}
+
+			return Run(opts.OptionsFromContext(ctx), moduleUrl, templateUrl)
+		},
 	}
 }
diff --git a/docs/_docs/04_reference/catalog-screenshot.png b/docs/_docs/04_reference/catalog-screenshot.png
new file mode 100644
index 0000000000..1b984089a5
Binary files /dev/null and b/docs/_docs/04_reference/catalog-screenshot.png differ
diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md
index 4430f51112..553cefaf3c 100644
--- a/docs/_docs/04_reference/cli-options.md
+++ b/docs/_docs/04_reference/cli-options.md
@@ -37,6 +37,7 @@ Terragrunt supports the following CLI commands:
   - [aws-provider-patch](#aws-provider-patch)
   - [render-json](#render-json)
   - [output-module-groups](#output-module-groups)
+  - [catalog](#catalog)
 
 ### All Terraform built-in commands
 
@@ -514,6 +515,26 @@ This may produce output such as:
 }
 ```
 
+### catalog
+
+Launch the user interface for searching and managing your module catalog.
+
+Example:
+
+```bash
+terragrunt catalog <repo-url>
+```
+
+[![Screenshot](https://terragrunt.gruntwork.io/docs/reference/cli-options/catalog-screenshot.png){ width=50% }](https://terragrunt.gruntwork.io/docs/reference/cli-options/catalog-screenshot.png)
+
+If `<repo-url>` is not specified, the modules are searched in the current directory. If a URL is provided, the repository will be copied to a temporary directory and deleted upon complete.
+
+This will recursively search for Terraform modules in the root of the repo and the `modules` directory and show a table with all the modules. You can then:
+1. Search and filter the table: `/` and start typing.
+1. Select a module in the table: use the arrow keys to go up and down and next/previous page.
+1. See the docs for a selected module: `ENTER`.
+1. Use [`terragrunt scaffold`](https://terragrunt.gruntwork.io/docs/features/scaffold/) to render a `terragrunt.hcl` for using the module: `S`.
+
 ## CLI options
 
 Terragrunt forwards all options to Terraform. The only exceptions are `--version` and arguments that start with the
diff --git a/go.mod b/go.mod
index 96b94ccf9f..3a2aae545c 100644
--- a/go.mod
+++ b/go.mod
@@ -56,13 +56,21 @@ require (
 )
 
 require (
+	github.com/charmbracelet/bubbles v0.16.1
+	github.com/charmbracelet/bubbletea v0.24.2
+	github.com/charmbracelet/glamour v0.6.0
+	github.com/charmbracelet/lipgloss v0.9.1
+	github.com/gitsight/go-vcsurl v1.0.1
 	github.com/gruntwork-io/boilerplate v0.5.7
 	github.com/gruntwork-io/go-commons v0.17.1
 	github.com/gruntwork-io/gruntwork-cli v0.7.0
+	github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
+	github.com/pkg/errors v0.9.1
 	github.com/posener/complete v1.2.3
 	github.com/urfave/cli/v2 v2.25.5
 	golang.org/x/term v0.13.0
 	golang.org/x/text v0.13.0
+	gopkg.in/ini.v1 v1.67.0
 )
 
 require (
@@ -86,6 +94,7 @@ require (
 	github.com/Masterminds/sprig/v3 v3.2.2 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20220407094043-a94812496cf5 // indirect
 	github.com/agext/levenshtein v1.2.3 // indirect
+	github.com/alecthomas/chroma v0.10.0 // indirect
 	github.com/apparentlymart/go-cidr v1.1.0 // indirect
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
 	github.com/apparentlymart/go-versions v1.0.1 // indirect
@@ -93,14 +102,19 @@ require (
 	github.com/armon/go-radix v1.0.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a // indirect
 	github.com/atomicgo/cursor v0.0.1 // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
+	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
 	github.com/blang/semver v3.5.1+incompatible // indirect
 	github.com/bmatcuk/doublestar v1.1.5 // indirect
 	github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
 	github.com/cenkalti/backoff/v3 v3.2.2 // indirect
+	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/dimchansky/utfbom v1.1.1 // indirect
+	github.com/dlclark/regexp2 v1.4.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/fatih/color v1.15.0 // indirect
 	github.com/frankban/quicktest v1.14.5 // indirect
@@ -117,6 +131,7 @@ require (
 	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
 	github.com/googleapis/gax-go/v2 v2.12.0 // indirect
 	github.com/gookit/color v1.5.0 // indirect
+	github.com/gorilla/css v1.0.0 // indirect
 	github.com/goware/prefixer v0.0.0-20160118172347-395022866408 // indirect
 	github.com/hashicorp/errwrap v1.1.0 // indirect
 	github.com/hashicorp/go-hclog v1.5.0 // indirect
@@ -142,23 +157,31 @@ require (
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 	github.com/kr/pretty v0.3.1 // indirect
 	github.com/lib/pq v1.10.5 // indirect
+	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.13 // indirect
-	github.com/mattn/go-isatty v0.0.17 // indirect
-	github.com/mattn/go-runewidth v0.0.13 // indirect
+	github.com/mattn/go-isatty v0.0.18 // indirect
+	github.com/mattn/go-localereader v0.0.1 // indirect
+	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
+	github.com/microcosm-cc/bluemonday v1.0.21 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
 	github.com/mitchellh/go-testing-interface v1.14.1 // indirect
 	github.com/mitchellh/panicwrap v1.0.0 // indirect
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
+	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
+	github.com/muesli/cancelreader v0.2.2 // indirect
+	github.com/muesli/reflow v0.3.0 // indirect
+	github.com/muesli/termenv v0.15.2 // indirect
 	github.com/oklog/run v1.1.0 // indirect
+	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/owenrumney/go-sarif v1.1.1 // indirect
 	github.com/pierrec/lz4 v2.6.1+incompatible // indirect
-	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/pterm/pterm v0.12.41 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	github.com/ryanuber/go-glob v1.0.0 // indirect
+	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/shopspring/decimal v1.2.0 // indirect
 	github.com/sourcegraph/go-lsp v0.0.0-20200429204803-219e11d77f5d // indirect
 	github.com/sourcegraph/jsonrpc2 v0.2.0 // indirect
@@ -170,6 +193,8 @@ require (
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect
 	github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
+	github.com/yuin/goldmark v1.5.2 // indirect
+	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 	github.com/zclconf/go-cty-yaml v1.0.3 // indirect
 	go.mozilla.org/gopgagent v0.0.0-20170926210634-4d7ea76ff71a // indirect
 	go.opencensus.io v0.24.0 // indirect
@@ -183,7 +208,6 @@ require (
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20231016165738-49dd2c1f3d0b // indirect
 	google.golang.org/grpc v1.59.0 // indirect
 	google.golang.org/protobuf v1.31.0 // indirect
-	gopkg.in/ini.v1 v1.66.4 // indirect
 	gopkg.in/square/go-jose.v2 v2.6.0 // indirect
 	gopkg.in/urfave/cli.v1 v1.20.0 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
diff --git a/go.sum b/go.sum
index 4072312334..b09f0780cb 100644
--- a/go.sum
+++ b/go.sum
@@ -278,6 +278,8 @@ github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki
 github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
 github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo=
 github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
+github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
+github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
@@ -312,12 +314,19 @@ github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a h1:idn718Q4
 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
 github.com/atomicgo/cursor v0.0.1 h1:xdogsqa6YYlLfM+GyClC/Lchf7aiMerFiZQn7soTOoU=
 github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk=
+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.15.78/go.mod h1:E3/ieXAlvM0XWO57iftYVDLLvQ824smPP3ATZkfNZeM=
 github.com/aws/aws-sdk-go v1.31.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
 github.com/aws/aws-sdk-go v1.37.0/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
 github.com/aws/aws-sdk-go v1.44.122/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
 github.com/aws/aws-sdk-go v1.46.6 h1:6wFnNC9hETIZLMf6SOTN7IcclrOGwp/n9SLp8Pjt6E8=
 github.com/aws/aws-sdk-go v1.46.6/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
+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/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
+github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f/go.mod h1:AuiFmCCPBSrqvVMvuqFuk0qogytodnVFVSN5CeJB8Gc=
 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
@@ -340,6 +349,14 @@ github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4r
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY=
+github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
+github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
+github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
+github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
+github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
+github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
+github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
 github.com/cheggaaa/pb v1.0.27/go.mod h1:pQciLPpbU0oxA0h+VJYYLxO+XeDQb5pZijXscXHm81s=
 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=
@@ -356,6 +373,8 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH
 github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
+github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 github.com/containerd/continuity v0.2.2 h1:QSqfxcn8c+12slxwu00AtzXrsami0MJb/MQs9lOLHLA=
 github.com/containerd/continuity v0.2.2/go.mod h1:pWygW9u7LtS1o4N/Tn0FoCFDIXZ7rxcMX7HX1Dmibvk=
 github.com/coreos/bbolt v1.3.0/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
@@ -377,6 +396,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
 github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8=
 github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
 github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
+github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
+github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
 github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -415,6 +436,8 @@ github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
 github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
 github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
+github.com/gitsight/go-vcsurl v1.0.1 h1:wkijKsbVg9R2IBP97U7wOANeIW9WJJKkBwS9XqllzWo=
+github.com/gitsight/go-vcsurl v1.0.1/go.mod h1:qRFdKDa/0Lh9MT0xE+qQBYZ/01+mY1H40rZUHR24X9U=
 github.com/go-asn1-ber/asn1-ber v1.3.1/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
 github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -449,8 +472,6 @@ github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
 github.com/gogo/protobuf v1.2.2-0.20190723190241-65acae22fc9d/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o=
 github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
-github.com/golang-jwt/jwt/v4 v4.3.0 h1:kHL1vqdqWNfATmA0FNMdmZNMyZI1U6O31X4rlIPoBog=
-github.com/golang-jwt/jwt/v4 v4.3.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg=
 github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
 github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -582,6 +603,8 @@ github.com/gophercloud/gophercloud v0.6.1-0.20191122030953-d8ac278c1c9d/go.mod h
 github.com/gophercloud/gophercloud v0.10.1-0.20200424014253-c3bfe50899e5/go.mod h1:gmC5oQqMDOMO1t1gq5DquX/yAU808e/4mzjjDA76+Ss=
 github.com/gophercloud/utils v0.0.0-20200423144003-7c72efc7435d/go.mod h1:ehWUbLQJPqS0Ep+CxeD559hsm9pthPXadJNKwZkp43w=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
+github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
+github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
 github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
 github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
@@ -781,6 +804,8 @@ github.com/likexian/gokit v0.20.15/go.mod h1:kn+nTv3tqh6yhor9BC4Lfiu58SmH8NmQ2Pm
 github.com/likexian/simplejson-go v0.0.0-20190409170913-40473a74d76d/go.mod h1:Typ1BfnATYtZ/+/shXfFYLrovhFyuKvzwrdOnIDHlmg=
 github.com/likexian/simplejson-go v0.0.0-20190419151922-c1f9f0b4f084/go.mod h1:U4O1vIJvIKwbMZKUJ62lppfdvkCdVd2nfMimHK81eec=
 github.com/likexian/simplejson-go v0.0.0-20190502021454-d8787b4bfa0b/go.mod h1:3BWwtmKP9cXWwYCr5bkoVDEfLywacOv0s06OBEDpyt8=
+github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
+github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/lusis/go-artifactory v0.0.0-20160115162124-7e4ce345df82/go.mod h1:y54tfGmO3NKssKveTEFFzH8C/akrSOy/iW9qEAUDV84=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/masterzen/simplexml v0.0.0-20160608183007-4572e39b1ab9/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc=
@@ -803,11 +828,17 @@ github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOA
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
-github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
-github.com/mattn/go-isatty v0.0.17/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-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.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
-github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
+github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
+github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
+github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/mattn/go-shellwords v1.0.4/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
 github.com/mattn/go-zglob v0.0.1/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb449FYHOo=
 github.com/mattn/go-zglob v0.0.3 h1:6Ry4EYsScDyt5di4OI6xw1bYhOqfE5S33Z1OPy+d+To=
@@ -815,6 +846,8 @@ github.com/mattn/go-zglob v0.0.3/go.mod h1:9fxibJccNxU2cnpIKLRRFA7zX7qhkJIQWBb44
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
+github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
+github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
 github.com/miekg/dns v1.0.8/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
 github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
 github.com/mitchellh/cli v1.1.2/go.mod h1:6iaV0fGdElS6dPBx0EApTxHrcWvmJphyh2n8YBLPPZ4=
@@ -853,6 +886,15 @@ github.com/modern-go/reflect2 v0.0.0-20180320133207-05fbef0ca5da/go.mod h1:bx2lN
 github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
+github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
+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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
+github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
+github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
+github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
+github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
 github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
@@ -860,6 +902,8 @@ github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH
 github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
 github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
 github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
+github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
+github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
 github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
@@ -884,6 +928,8 @@ github.com/pierrec/lz4 v2.5.2+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
 github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
 github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pkg/browser v0.0.0-20201207095918-0426ae3fba23/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ=
+github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
+github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
 github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -924,6 +970,7 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b
 github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
 github.com/pterm/pterm v0.12.41 h1:e2BRfFo1H9nL8GY0S3ImbZqfZ/YimOk9XtkhoobKJVs=
 github.com/pterm/pterm v0.12.41/go.mod h1:LW/G4J2A42XlTaPTAGRPvbBfF4UXvHWhC6SN7ueU4jU=
+github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
@@ -937,6 +984,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
 github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
 github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
 github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
+github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
@@ -1032,6 +1081,10 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
+github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
+github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
 github.com/zclconf/go-cty v1.0.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
 github.com/zclconf/go-cty v1.1.0/go.mod h1:xnAOWiHeOqg2nWS62VtQ7pbOu17FtxJNW8RLEih+O3s=
 github.com/zclconf/go-cty v1.2.0/go.mod h1:hOPWgoHbaTUnI5k4D2ld+GRpFJSCe6bCM7m1q/N4PQ8=
@@ -1186,6 +1239,7 @@ golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
+golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
@@ -1292,6 +1346,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 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-20210616094352-59db8d763f22/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-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1319,6 +1374,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
 golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
@@ -1653,8 +1709,8 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
 gopkg.in/inf.v0 v0.9.0/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
 gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
-gopkg.in/ini.v1 v1.66.4 h1:SsAcf+mM7mRZo2nJNGt8mZCjG8ZRaNGMURJw7BsIST4=
-gopkg.in/ini.v1 v1.66.4/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
+gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
 gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
diff --git a/pkg/log/exported.go b/pkg/log/exported.go
new file mode 100644
index 0000000000..fb6fedf429
--- /dev/null
+++ b/pkg/log/exported.go
@@ -0,0 +1,113 @@
+package log
+
+import "github.com/sirupsen/logrus"
+
+// WithError adds an error to log entry, using the value defined in ErrorKey as key.
+func WithError(err error) *logrus.Entry {
+	return logger.WithError(err)
+}
+
+// Debug logs a message at level Debug on the standard logger.
+func Debug(args ...any) {
+	logger.Debug(args...)
+}
+
+// Info logs a message at level Info on the standard logger.
+func Info(args ...any) {
+	logger.Info(args...)
+}
+
+// Print logs a message at level Info on the standard logger.
+func Print(args ...any) {
+	logger.Print(args...)
+}
+
+// Warn logs a message at level Warn on the standard logger.
+func Warn(args ...any) {
+	logger.Warn(args...)
+}
+
+// Error logs a message at level Error on the standard logger.
+func Error(args ...any) {
+	logger.Error(args...)
+}
+
+// Panic logs a message at level Panic on the standard logger.
+func Panic(args ...any) {
+	logger.Panic(args...)
+}
+
+// Fatal logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
+func Fatal(args ...any) {
+	logger.Fatal(args...)
+}
+
+// Debugln logs a message at level Debug on the standard logger.
+func Debugln(args ...any) {
+	logger.Debugln(args...)
+}
+
+// Infoln logs a message at level Info on the standard logger.
+func Infoln(args ...any) {
+	logger.Infoln(args...)
+}
+
+// Println logs a message at level Info on the standard logger.
+func Println(args ...any) {
+	logger.Println(args...)
+}
+
+// Warnln logs a message at level Warn on the standard logger.
+func Warnln(args ...any) {
+	logger.Warnln(args...)
+}
+
+// Errorln logs a message at level Error on the standard logger.
+func Errorln(args ...any) {
+	logger.Errorln(args...)
+}
+
+// Panicln logs a message at level Panic on the standard logger.
+func Panicln(args ...any) {
+	logger.Panicln(args...)
+}
+
+// Fatalln logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
+func Fatalln(args ...any) {
+	logger.Fatalln(args...)
+}
+
+// Debugf logs a message at level Debug on the standard logger.
+func Debugf(format string, args ...any) {
+	logger.Debugf(format, args...)
+}
+
+// Infof logs a message at level Info on the standard logger.
+func Infof(format string, args ...any) {
+	logger.Infof(format, args...)
+}
+
+// Printf logs a message at level Info on the standard logger.
+func Printf(args ...any) {
+	logger.Print(args...)
+}
+
+// Warnf logs a message at level Warn on the standard logger.
+func Warnf(format string, args ...any) {
+	logger.Warnf(format, args...)
+}
+
+// Errorf logs a message at level Error on the standard logger.
+func Errorf(format string, args ...any) {
+	logger.Errorf(format, args...)
+}
+
+// Panicf logs a message at level Panic on the standard logger.
+func Panicf(format string, args ...any) {
+	logger.Panicf(format, args...)
+}
+
+// Fatalf logs a message at level Fatal on the standard logger then the process will exit with status set to 1.
+func Fatalf(format string, args ...any) {
+	logger.Fatalf(format, args...)
+}
diff --git a/pkg/log/logger.go b/pkg/log/logger.go
new file mode 100644
index 0000000000..960cc59887
--- /dev/null
+++ b/pkg/log/logger.go
@@ -0,0 +1,34 @@
+package log
+
+import (
+	"sync"
+
+	"github.com/sirupsen/logrus"
+)
+
+var (
+	logger       *logrus.Logger
+	logLevelLock = sync.Mutex{}
+)
+
+func SetLogLevel(level logrus.Level) {
+	// We need to lock here as this function may be called from multiple threads concurrently (e.g. especially at test time)
+	defer logLevelLock.Unlock()
+	logLevelLock.Lock()
+
+	logger.Level = level
+}
+
+// Logger returns logger
+func Logger() *logrus.Logger {
+	return logger
+}
+
+// Logger returns logger
+func SetLogger(l *logrus.Logger) {
+	logger = l
+}
+
+func init() {
+	logger = logrus.New()
+}


Test patch:
diff --git a/cli/app_test.go b/cli/app_test.go
index df59df36de..26b1390cd0 100644
--- a/cli/app_test.go
+++ b/cli/app_test.go
@@ -476,7 +476,7 @@ func TestAutocomplete(t *testing.T) {
 	}{
 		{
 			"",
-			[]string{"aws-provider-patch", "graph-dependencies", "hclfmt", "output-module-groups", "render-json", "run-all", "scaffold", "terragrunt-info", "validate-inputs"},
+			[]string{"aws-provider-patch", "graph-dependencies", "hclfmt", "output-module-groups", "render-json", "run-all", "terragrunt-info", "validate-inputs"},
 		},
 		{
 			"--versio",
@@ -497,6 +497,7 @@ func TestAutocomplete(t *testing.T) {
 
 		output := &bytes.Buffer{}
 		app := NewApp(output, os.Stderr)
+		app.Commands = app.Commands.Filter([]string{"aws-provider-patch", "graph-dependencies", "hclfmt", "output-module-groups", "render-json", "run-all", "terragrunt-info", "validate-inputs"})
 
 		err := app.Run([]string{"terragrunt"})
 		require.NoError(t, err)
diff --git a/cli/commands/catalog/module/repo_test.go b/cli/commands/catalog/module/repo_test.go
new file mode 100644
index 0000000000..b58e8bde14
--- /dev/null
+++ b/cli/commands/catalog/module/repo_test.go
@@ -0,0 +1,71 @@
+package module
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestFindModules(t *testing.T) {
+	t.Parallel()
+
+	testCases := []struct {
+		repoPath        string
+		expectedModules Modules
+		expectedErr     error
+	}{
+		{
+			"testdata/find_modules/terraform-aws-eks",
+			Modules{
+				&Module{
+					title:       "ALB Ingress Controller Module",
+					description: "This Terraform Module installs and configures the [AWS ALB Ingress Controller](https://github.com/kubernetes-sigs/aws-alb-ingress-controller) on an EKS cluster.",
+					url:         "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-alb-ingress-controller",
+					moduleDir:   "modules/eks-alb-ingress-controller",
+				},
+				&Module{
+					title:       "ALB Ingress Controller IAM Policy Module",
+					description: "This Terraform Module defines an [IAM policy](http://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/QuickStartEC2Instance.html#d0e22325)  that defines the minimal set of permissions necessary for the [AWS ALB Ingress Controller]",
+					url:         "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-alb-ingress-controller-iam-policy",
+					moduleDir:   "modules/eks-alb-ingress-controller-iam-policy",
+				},
+				&Module{
+					title:       "EKS AWS Auth Merger",
+					description: "This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes. The official way to manage the mapping is to add values in a single, central `ConfigMap`.  This module allows you to break up the central `ConfigMap` across multiple.   toc::[]",
+					url:         "https://github.com/gruntwork-io/terraform-aws-eks/tree/master/modules/eks-aws-auth-merger",
+					moduleDir:   "modules/eks-aws-auth-merger",
+				}},
+			nil,
+		},
+	}
+
+	for _, testCase := range testCases {
+		testCase := testCase
+
+		t.Run(testCase.repoPath, func(t *testing.T) {
+			t.Parallel()
+			// Unfortunately, we are unable to commit the `.git` directory. We have to temporarily rename it while running the tests.
+			os.Rename(filepath.Join(testCase.repoPath, "gitdir"), filepath.Join(testCase.repoPath, ".git"))
+			defer os.Rename(filepath.Join(testCase.repoPath, ".git"), filepath.Join(testCase.repoPath, "gitdir"))
+
+			ctx := context.Background()
+
+			repo, err := NewRepo(ctx, testCase.repoPath)
+			assert.NoError(t, err)
+
+			modules, err := repo.FindModules(ctx)
+
+			for _, module := range modules {
+				module.repoPath = ""
+				module.readme = ""
+			}
+
+			assert.Equal(t, testCase.expectedModules, modules)
+			assert.Equal(t, testCase.expectedErr, err)
+		})
+	}
+
+}
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/HEAD b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/HEAD
new file mode 100644
index 0000000000..cb089cd89a
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/HEAD
@@ -0,0 +1,1 @@
+ref: refs/heads/master
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/config b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/config
new file mode 100644
index 0000000000..10e1eb516f
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/gitdir/config
@@ -0,0 +1,13 @@
+[core]
+	repositoryformatversion = 0
+	filemode = true
+	bare = false
+	logallrefupdates = true
+	ignorecase = true
+	precomposeunicode = true
+[remote "origin"]
+	url = https://github.com/gruntwork-io/terraform-aws-eks
+	fetch = +refs/heads/*:refs/remotes/origin/*
+[branch "master"]
+	remote = origin
+	merge = refs/heads/master
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/README.md b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/README.md
new file mode 100644
index 0000000000..c18eead7bd
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/README.md
@@ -0,0 +1,41 @@
+# ALB Ingress Controller IAM Policy Module
+
+This Terraform Module defines an [IAM
+policy](http://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/QuickStartEC2Instance.html#d0e22325)
+
+that defines the minimal set of permissions necessary for the [AWS ALB Ingress Controller]
+
+
+## How do you use this module?
+
+* See the [root README](/README.adoc) for instructions on using Terraform modules.
+* See the [eks-cluster-with-supporting-services example](/examples/eks-cluster-with-supporting-services) for example
+  usage.
+* See [variables.tf](./variables.tf) for all the variables you can set on this module.
+* See [outputs.tf](./outputs.tf) for all the variables that are outputed by this module.
+
+
+## Attaching IAM policy to workers
+
+To allow the ALB Ingress Controller to manage ALBs, it needs IAM permissions to use the AWS API to manage ALBs.
+Currently, the way to grant Pods IAM privileges is to use the worker IAM profiles provisioned by [the
+eks-cluster-workers module](/modules/eks-cluster-workers/README.md#how-do-you-add-additional-iam-policies).
+
+The Terraform templates in this module create an IAM policy that has the required permissions. You then need to use an
+[aws_iam_policy_attachment](https://www.terraform.io/docs/providers/aws/r/iam_policy_attachment.html) to attach that
+policy to the IAM roles of your EC2 Instances.
+
+```hcl
+module "eks_workers" {
+  # (arguments omitted)
+}
+
+module "alb_ingress_controller_iam_policy" {
+  # (arguments omitted)
+}
+
+resource "aws_iam_role_policy_attachment" "attach_alb_ingress_controller_iam_policy" {
+    role = "${module.eks_workers.eks_worker_iam_role_name}"
+    policy_arn = "${module.alb_ingress_controller_iam_policy.alb_ingress_controller_policy_arn}"
+}
+```
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/main.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/main.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/variables.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller-iam-policy/variables.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/README.md b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/README.md
new file mode 100644
index 0000000000..85f3de8ee8
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/README.md
@@ -0,0 +1,212 @@
+# ALB Ingress Controller Module
+
+This Terraform Module installs and configures the [AWS ALB Ingress
+Controller](https://github.com/kubernetes-sigs/aws-alb-ingress-controller) on an EKS cluster.
+
+#### Note: v2
+We're now supporting v2 of the AWS Load Balancer Ingress Controller. The AWS Load Balancer Ingress Controller v2 has many new features, and is considered backwards incompatible with the existing AWS resources it manages. Please note, that it can't coexist with the existing/older version, so you must fully undeploy the old version prior to updating. For the migration steps, please refer to the [relevant Release notes for this module](https://github.com/gruntwork-io/terraform-aws-eks/releases/tag/v0.28.0).
+
+## How does this work?
+
+This module solves the problem of integrating Kubernetes `Service` endpoints with an
+[ALB](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html). Out of the box Kubernetes
+supports tying [a `Service` to an ELB or NLB using the `LoadBalancer`
+type](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/). However, the
+`LoadBalancer` `Service` type does not support ALBs, and thus you can not implement complex routing rules based on
+domain or paths.
+
+Kubernetes uses `Ingress` resources to configure and implement "Layer 7" load balancers (where ALBs fit in the [OSI
+model](https://en.wikipedia.org/wiki/OSI_model#Layer_7:_Application_Layer)). Kubernetes `Ingress` works by providing a
+configuration framework to configure routing rules from a load balancer to `Services` within Kubernetes. For example,
+suppose you wanted to provision a `Service` for your backend, fronted by a load balancer that routes any request made to
+the path `/service` to the backend. To do so, in addition to creating your `Service`, you would create an `Ingress`
+resource in Kubernetes that configures the routing rule:
+
+```yaml
+---
+kind: Service
+apiVersion: v1
+metadata:
+  name: backend
+spec:
+  selector:
+    app: backend
+  ports:
+  - protocol: TCP
+    port: 80
+    targetPort: 80
+---
+apiVersion: extensions/v1beta1
+kind: Ingress
+metadata:
+  name: service-ingress
+spec:
+  rules:
+  - http:
+      paths:
+      - path: /service
+        backend:
+          serviceName: backend
+          servicePort: 80
+```
+
+In the above configuration, we create a Cluster IP based `Service` (so that it is only available internally to the
+Kubernetes cluster) that routes requests to port 80 to any `Pod` that maches the label `app=backend` on port 80. Then,
+we configure an `Ingress` rule that routes any requests prefixed with `/service` to that `Service` endpoint on port 80.
+
+The actual load balancer that is configured by the `Ingress` resource is defined by the particular [Ingress
+Controller](https://kubernetes.io/docs/concepts/services-networking/ingress-controllers/) that you deploy onto your
+Kubernetes cluster. Ingress Controllers are separate processes that run on your Kubernetes cluster that will watch for
+`Ingress` resources and reflect them by provisioning or configuring load balancers. Depending on which controller you
+use, the particular load balancer that is provisioned will be different. For example, if you use the [official nginx
+controller](https://github.com/kubernetes/ingress-nginx/blob/e222b74/README.md), each `Ingress` resource translates into
+an nginx `Pod` that implements the routing rules.
+
+Note that each `Ingress` resource defines a separate load balancer. This means that each time you create a new `Ingress`
+resource in Kubernetes, Kubernetes will provision a new load balancer configured with the rules defined by the `Ingress`
+resource.
+
+This module deploys the AWS ALB Ingress Controller, which will reflect each `Ingress` resource into an ALB resource
+deployed into your AWS account.
+
+## How do you use this module?
+
+* See the [root README](/README.adoc) for instructions on using Terraform modules.
+* See the [eks-cluster-with-supporting-services example](/examples/eks-cluster-with-supporting-services) for example
+  usage.
+* See [variables.tf](./variables.tf) for all the variables you can set on this module.
+* This module uses [the `kubernetes` provider](https://www.terraform.io/docs/providers/kubernetes/index.html).
+* This module uses [the `helm` provider](https://www.terraform.io/docs/providers/helm/index.html).
+
+## Prerequisites
+
+### Helm setup
+
+This module uses [`helm` v3](https://helm.sh/docs/) to deploy the controller to the Kubernetes cluster.
+
+### ALB Target Type
+
+The ALB Ingress Controller application can configure ALBs to send work either to Node IPs (`instance`) or Pod IPs (`ip`) as backend targets. This can be specified in the Ingress object using the [`alb.ingress.kubernetes.io/target-type`](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/ingress/annotation/#target-type). The default is `instance`.
+
+When using the default `instance` target type, the `Services` intended to be consumed by the `Ingress` resource must be
+provisioned using the `NodePort` type. This is not required when using the `ip` target type.
+
+Note that the controller will take care of setting up the target groups on the provisioned ALB so that everything routes
+correctly.
+
+### Subnets
+
+You can use the `alb.ingress.kubernetes.io/subnets` annotation on `Ingress` resources to specify which subnets the controller should configure the ALB for.
+
+You can also omit the `alb.ingress.kubernetes.io/subnets` annotation, and the controller will [automatically discover subnets](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/controller/config/#subnet-auto-discovery) based on their tags. This method should work "out of the box", so long as you are using the [`eks-vpc-tags`](../eks-vpc-tags) module to tag your VPC subnets.
+
+### Security Groups
+
+As mentioned above under the [ALB Target Type](#alb-target-type) section, the default ALB target type uses node ports to connect to the
+`Services`. As such if you have restricted security groups that prevent access to the provisioned ports on the worker
+nodes, the ALBs will not be able to reach the `Services`.
+
+To ensure the provisioned ALBs can access the node ports, we recommend using dedicated subnets for load balancing and
+configuring your security groups so that resources provisioned in those subnets can access the node ports of the worker
+nodes.
+
+### IAM permissions
+
+The container deployed in this module requires IAM permissions to manage ALB resources. See [the
+eks-alb-ingress-controller-iam-policy module](../eks-alb-ingress-controller-iam-policy) for more information.
+
+## Using the Ingress Controller
+
+In order for the `Ingress` resources to properly map into an ALB, the `Ingress` resources created need to be annotated
+to use the `alb` `Ingress` class. You can do this by adding the following annotation to your `Ingress` resources:
+
+```yaml
+annotations:
+  kubernetes.io/ingress.class: alb
+```
+
+The ALB Ingress Controller supports a wide range of configuration options via annotations on the `Ingress` object, including setting up Cognito for
+authentication. For example, you can add the annotation `alb.ingress.kubernetes.io/scheme: internet-facing` to provision
+a public ALB. You can refer to the [official
+documentation](https://kubernetes-sigs.github.io/aws-alb-ingress-controller/guide/ingress/annotation/) for the full
+reference of configuration options supported by the controller.
+
+## Getting the ALB endpoint
+
+The ALB endpoint is recorded on the `Ingress` resource. You can use `kubectl` or the Kubernetes API to retrieve the
+`Ingress` resource and view the endpoint for the ALB under the `Address` attribute.
+
+For example, suppose you provisioned the following `Ingress` resource in the default namespace:
+
+```yaml
+---
+apiVersion: extensions/v1beta1
+kind: Ingress
+metadata:
+  name: service-ingress
+  annotations:
+    kubernetes.io/ingress.class: alb
+spec:
+  rules:
+  - http:
+      paths:
+      - path: /service
+        backend:
+          serviceName: backend
+          servicePort: 80
+```
+
+To get the ALB endpoint, call `kubectl` to describe the `Ingress` resource:
+
+```
+$ kubectl describe ing service-ingress
+Name:                   service-ingress
+Namespace:              default
+Address:                QZVpvauzhSuRBRMfjAGnbgaCaLeANaoe.us-east-2.elb.amazonaws.com
+Default backend:        default-http-backend:80 (10.2.1.28:8080)
+Rules:
+  Host                          Path      Backends
+  ----                          ----      --------
+                                /service  backend:80 (<none>)
+Annotations:
+Events:
+  FirstSeen     LastSeen        Count   From                    SubObjectPath   Type            Reason  Message
+  ---------     --------        -----   ----                    -------------   --------        ------  -------
+  3m            3m              1       ingress-controller                      Normal          CREATE  Ingress service-ingress/backend
+  3m            32s             3       ingress-controller                      Normal          UPDATE  Ingress service-ingress/backend
+```
+
+Note how the ALB endpoint is recorded under the `Address` column. You can hit that endpoint to access the service
+externally.
+
+## DNS records for the ALB
+
+In order for the host based routing rules to work with the ALB, you need to configure your DNS records to point to the
+ALB endpoint. This can be tricky if you are managing your DNS records externally, especially given the asynchronous
+nature of the controller in provisioning the ALBs.
+
+The AWS ALB Ingress Controller has first class support for
+[external-dns](https://github.com/kubernetes-incubator/external-dns), a third party tool that configures external DNS
+providers with domains to route to `Services` and `Ingresses` in Kubernetes. See our [eks-k8s-external-dns
+module](../eks-k8s-external-dns) for more information on how to setup the tool.
+
+
+## How do I deploy the Pods to Fargate?
+
+To deploy the Pods to Fargate, you can use the `create_fargate_profile` variable to `true` and specify the subnet IDs
+for Fargate using `vpc_worker_subnet_ids`. Note that if you are using Fargate, you must rely on the IAM Roles for
+Service Accounts (IRSA) feature to grant the necessary AWS IAM permissions to the Pod. This is configured using the
+`use_iam_role_for_service_accounts`, `eks_openid_connect_provider_arn`, and `eks_openid_connect_provider_url` input
+variables.
+
+
+## How does the ALB route to Fargate?
+
+For Pods deployed to Fargate, you must specify the annotation
+
+```
+alb.ingress.kubernetes.io/target-type: ip
+```
+
+to the Ingress resource in order for the ALB to route properly. This is because Fargate does not have actual EC2
+instances under the hood, and thus the ALB can not be configured to route by instance (the default configuration).
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/main.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/main.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/variables.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-alb-ingress-controller/variables.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/README.adoc b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/README.adoc
new file mode 100644
index 0000000000..38a27e47af
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/README.adoc
@@ -0,0 +1,129 @@
+:type: service
+:name: EKS AWS Auth Merger
+:description: Manage the aws-auth ConfigMap across multiple independent ConfigMaps.
+:icon: /_docs/iam-role-icon.png
+:category: docker-orchestration
+:cloud: aws
+:tags: docker, orchestration, kubernetes, containers
+:license: gruntwork
+:built-with: go, terraform
+
+// AsciiDoc TOC settings
+:toc:
+:toc-placement!:
+:toc-title:
+
+// GitHub specific settings. See https://gist.github.com/dcode/0cfbf2699a1fe9b46ff04c41721dda74 for details.
+ifdef::env-github[]
+:tip-caption: :bulb:
+:note-caption: :information_source:
+:important-caption: :heavy_exclamation_mark:
+:caution-caption: :fire:
+:warning-caption: :warning:
+endif::[]
+
+= EKS AWS Auth Merger
+
+image:https://img.shields.io/badge/maintained%20by-gruntwork.io-%235849a6.svg[link="https://gruntwork.io/?ref=repo_aws_eks"]
+image:https://img.shields.io/badge/tf-%3E%3D1.1.0-blue[Terraform version]
+image:https://img.shields.io/badge/k8s-1.23%20~%201.27-5dbcd2[K8s version]
+
+This module contains a go CLI, docker container, and terraform module for deploying a Kubernetes controller for managing
+mappings between AWS IAM roles and users to RBAC groups in Kubernetes. The official way to manage the mapping is to add
+values in a single, central `ConfigMap`.
+
+This module allows you to break up the central `ConfigMap` across multiple.
+
+
+toc::[]
+
+
+
+
+== Features
+
+* Break up the `aws-auth` Kubernetes `ConfigMap` across multiple objects.
+* Automatically merge new `ConfigMaps` as they are added and removed.
+* Track automatically generated `aws-auth` source `ConfigMaps` that are generated by EKS.
+
+
+
+== Learn
+
+NOTE: This repo is a part of https://gruntwork.io/infrastructure-as-code-library/[the Gruntwork Infrastructure as Code
+Library], a collection of reusable, battle-tested, production ready infrastructure code. If you've never used the Infrastructure as Code Library before, make sure to read https://gruntwork.io/guides/foundations/how-to-use-gruntwork-infrastructure-as-code-library/[How to use the Gruntwork Infrastructure as Code Library]!
+
+=== Core concepts
+
+* _link:/modules/eks-k8s-role-mapping/README.md#what-is-kubernetes-role-based-access-control-rbac[What is Kubernetes
+  RBAC?]_: overview of Kubernetes RBAC, the underlying system managing authentication and authorization in Kubernetes.
+
+* _link:/modules/eks-k8s-role-mapping/README.md#what-is-aws-iam-role[What is AWS IAM role?]_: overview of AWS IAM Roles,
+  the underlying system managing authentication and authorization in AWS.
+
+* _https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html[Managing users or IAM roles for your cluster]_:
+  The official AWS docs on how the `aws-auth` Kubernetes `ConfigMap` works.
+
+* _link:core-concepts.md#what-is-the-aws-auth-merger[What is the aws-auth-merger?]_: overview of the `aws-auth-merger`
+  and how it works to manage the `aws-auth` Kubernetes `ConfigMap`.
+
+
+=== Repo organization
+
+* link:/modules[modules]: the main implementation code for this repo, broken down into multiple standalone, orthogonal submodules.
+* link:/examples[examples]: This folder contains working examples of how to use the submodules.
+* link:/test[test]: Automated tests for the modules and examples.
+
+
+== Deploy
+
+=== Non-production deployment (quick start for learning)
+
+If you just want to try this repo out for experimenting and learning, check out the following resources:
+
+* link:/examples[examples folder]: The `examples` folder contains sample code optimized for learning, experimenting, and testing (but not production usage).
+
+=== Production deployment
+
+If you want to deploy this repo in production, check out the following resources:
+
+* https://gruntwork.io/guides/kubernetes/how-to-deploy-production-grade-kubernetes-cluster-aws/#deployment_walkthrough[How to deploy a production-grade Kubernetes cluster on AWS]: A step-by-step guide for deploying a production-grade EKS cluster on AWS using the code in this repo.
+
+**EKS Cluster**: Production-ready example code from the Reference Architecture:
+* https://github.com/gruntwork-io/terraform-aws-service-catalog/blob/main/examples/for-production/infrastructure-live/prod/us-west-2/prod/services/eks-cluster/terragrunt.hcl[app account configuration]
+* https://github.com/gruntwork-io/terraform-aws-service-catalog/blob/main/examples/for-production/infrastructure-live/_envcommon/services/eks-cluster.hcl[base configuration]
+
+
+
+
+== Manage
+
+* link:core-concepts.md#how-do-i-use-the-aws-auth-merger[How to deploy and use the aws-auth-merger]
+* link:core-concepts.md#how-do-i-handle-conflicts-with-automatic-updates-by-eks[How to handle conflicts with automatic
+  updates to the aws-auth ConfigMap by EKS]
+* link:/modules/eks-k8s-role-mapping/README.md#restricting-specific-actions[How to restrict users to specific actions on the EKS cluster]
+* link:/modules/eks-k8s-role-mapping/README.md#restricting-by-namespace[How to restrict users to specific namespaces on the EKS cluster]
+* link:/core-concepts.md#how-to-authenticate-kubectl[How to authenticate kubectl to EKS]
+
+
+
+
+== Support
+
+If you need help with this repo or anything else related to infrastructure or DevOps, Gruntwork offers https://gruntwork.io/support/[Commercial Support] via Slack, email, and phone/video. If you're already a Gruntwork customer, hop on Slack and ask away! If not, https://www.gruntwork.io/pricing/[subscribe now]. If you're not sure, feel free to email us at link:mailto:support@gruntwork.io[support@gruntwork.io].
+
+
+
+
+== Contributions
+
+Contributions to this repo are very welcome and appreciated! If you find a bug or want to add a new feature or even contribute an entirely new module, we are very happy to accept pull requests, provide feedback, and run your changes through our automated test suite.
+
+Please see https://gruntwork.io/guides/foundations/how-to-use-gruntwork-infrastructure-as-code-library/#contributing-to-the-gruntwork-infrastructure-as-code-library[Contributing to the Gruntwork Infrastructure as Code Library] for instructions.
+
+
+
+
+== License
+
+Please see link:LICENSE.md[LICENSE.md] for details on how the code in this repo is licensed.
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/core-concepts.md b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/core-concepts.md
new file mode 100644
index 0000000000..2da0061c35
--- /dev/null
+++ b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/core-concepts.md
@@ -0,0 +1,83 @@
+## What is the aws-auth-merger?
+
+The `aws-auth-merger` is a go CLI intended to be run inside a Pod in an EKS cluster (as opposed to a CLI tool used by the
+operator) for managing mappings between AWS IAM roles and users to RBAC groups in Kubernetes, and is an alternative to
+[the official way AWS recommends managing the
+mappings](https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html).
+The official way to manage the mapping is to add values in a single, central `ConfigMap`. This central `ConfigMap` has a
+few challenges:
+
+- The updates are not managed as code if you are manually updating the `ConfigMap`. This can be a problem when you want
+  to spin up a new cluster with the same configuration, as you now have to download the `ConfigMap` and replicate it
+  into the new cluster.
+
+- The [eks-k8s-role-mapping module](../eks-k8s-role-mapping) allows you to manage the central `ConfigMap` as code.
+  However, EKS will create the `ConfigMap` under certain conditions (e.g. to allow access to Fargate), and depending on
+  timing, you can end up with an error where terraform is not able to create the `ConfigMap` until you import it into
+  the state.
+
+- A single typo or mistake can disable the entire `ConfigMap`. For example, if you have a syntactic yaml error in the
+  central `ConfigMap`, it will prevent EKS from being able to read the `ConfigMap`, thereby disabling access to all
+  the users captured in the `ConfigMap`.
+
+The `aws-auth-merger` can be used to address these challenges by breaking up the central `ConfigMap` across multiple
+`ConfigMaps` that are tracked in a separate place. The `aws-auth-merger` watches for `aws-auth` compatible `ConfigMaps`
+that can be merged to manage the `aws-auth` authentication `ConfigMap` for EKS.
+
+The `aws-auth-merger` works as follows:
+
+- When starting up, the `aws-auth-merger` will scan if the main `aws-auth` `ConfigMap` already exists in the
+  `kube-system` namespace. The `aws-auth-merger` checks if the `ConfigMap` was created by the merger, and if not, will
+  snapshot the `ConfigMap` so that it will be included in the merge.
+- The `aws-auth-merger` then does an initial merger of all the `ConfigMaps` in the configured namespace to create the
+  initial version of the main `aws-auth` `ConfigMap`.
+- The `aws-auth-merger` then enters an infinite event loop that watches for changes to the `ConfigMaps` in the
+  configured namespace. The syncing routine will run everytime the merger detects changes in the namespace.
+
+## How do I use the aws-auth-merger?
+
+To deploy the `aws-auth-merger`, follow the following steps:
+
+1. Create a docker repository to house the `aws-auth-merger`. We recommend using ECR.
+1. Build a Docker image that runs the `aws-auth-merger` and push the container to ECR.
+1. Deploy this module using terraform:
+    1. Set the `aws_auth_merger_image` variable to point to the ECR repo and tag for the `aws-auth-merger` docker image.
+    1. Set additional variables as needed.
+
+If you wish to manually deploy the `aws-auth-merger` without using Terraform, you can deploy a `Deployment` with a
+single replica using the image. The `ServiceAccount` that you associate with the `Pods` in the `Deployment` needs to be
+able to:
+
+- `get`, `list`, `create`, and `watch` for `ConfigMaps` in the namespace that it is watching.
+- `get`, `create`, and `update` the `aws-auth` `ConfigMap` in the `kube-system`.
+
+Once the `aws-auth-merger` is deployed, you can create `ConfigMaps` in the watched namespace that mimic the `aws-auth`
+`ConfigMap`. Refer to [the official AWS docs](https://docs.aws.amazon.com/eks/latest/userguide/add-user-role.html) for
+more information on the format of the `aws-auth` `ConfigMap`.
+
+For convenience, you can use the [eks-k8s-role-mapping](../eks-k8s-role-mapping) module to manage each individual
+`aws-auth` `ConfigMap` to be merged by the merger. Refer to the [eks-cluster-with-iam-role-mappings
+example](/example/eks-cluster-with-iam-role-mappings) for an example of how to integrate the two modules.
+
+## How do I handle conflicts with automatic updates by EKS?
+
+EKS will automatically update or create the central `aws-auth` `ConfigMap`. This can lead to conflicts with the
+`aws-auth-merger`, including potential data loss that locks out Fargate or Managed Node Group workers. To handle these
+conflicts, we recommend the following approach:
+
+- If you are using Fargate for the Control Plane components (e.g. CoreDNS) or for the `aws-auth-merger` itself, ensure
+  that the relevant Fargate Profiles are created prior to the initial deployment of the `aws-auth-merger`. This ensures
+  that AWS constructs the `aws-auth` `ConfigMap` before the `aws-auth-merger` comes online, allowing it to snapshot the
+  existing `ConfigMap` to be merged in to the managed central `ConfigMap`.
+
+- If you are using Fargate outside of the `aws-auth-merger`, ensure that you create the Fargate Profile after the
+  `aws-auth-merger` is deployed. Then, create an `aws-auth` `ConfigMap` in the merger namespace that includes the
+  Fargate execution role (the input variable `eks_fargate_profile_executor_iam_role_arns` in the
+  `eks-k8s-role-mapping` module). This ensures that the Fargate execution role is included in the merged `ConfigMap`.
+
+- If you are using Managed Node Groups, you have two options:
+    - Ensure that the Managed Node Group is created prior to the `aws-auth-merger` being deployed. This ensures that AWS
+      constructs the `aws-auth` `ConfigMap` before the `aws-auth-merger` comes online.
+    - If you wish to create Managed Node Groups after the `aws-auth-merger` is deployed, ensure that the worker IAM role
+      of the Managed Node Group is included in an `aws-auth` `ConfigMap` in the merger namespace (the input variable
+      `eks_worker_iam_role_arns`).
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/main.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/main.tf
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/variables.tf b/cli/commands/catalog/module/testdata/find_modules/terraform-aws-eks/modules/eks-aws-auth-merger/variables.tf
new file mode 100644
index 0000000000..e69de29bb2