diff --git a/apps/cnquery/cmd/plugin.go b/apps/cnquery/cmd/plugin.go index 0d9ccd5b22..2b2a5abf68 100644 --- a/apps/cnquery/cmd/plugin.go +++ b/apps/cnquery/cmd/plugin.go @@ -152,8 +152,8 @@ func (c *cnqueryPlugin) RunQuery(conf *run.RunQueryConfig, runtime *providers.Ru // m.StoreRecording(viper.GetString("record-file")) } - shellOptions := []shell.ShellOption{} - shellOptions = append(shellOptions, shell.WithOnCloseListener(onCloseHandler)) + shellOptions := []shell.Option{} + shellOptions = append(shellOptions, shell.WithOnClose(onCloseHandler)) shellOptions = append(shellOptions, shell.WithFeatures(conf.Features)) shellOptions = append(shellOptions, shell.WithOutput(out)) @@ -161,10 +161,7 @@ func (c *cnqueryPlugin) RunQuery(conf *run.RunQueryConfig, runtime *providers.Ru shellOptions = append(shellOptions, shell.WithUpstreamConfig(upstreamConfig)) } - sh, err := shell.New(asset.Runtime, shellOptions...) - if err != nil { - return errors.Wrap(err, "failed to initialize the shell") - } + sh := shell.NewShell(asset.Runtime, shellOptions...) defer func() { // prevent the recording from being closed multiple times err = asset.Runtime.SetRecording(recording.Null{}) diff --git a/apps/cnquery/cmd/shell.go b/apps/cnquery/cmd/shell.go index e04d5a1f60..697fe09695 100644 --- a/apps/cnquery/cmd/shell.go +++ b/apps/cnquery/cmd/shell.go @@ -159,19 +159,31 @@ func StartShell(runtime *providers.Runtime, conf *ShellConfig) error { providers.Coordinator.Shutdown() } - shellOptions := []shell.ShellOption{} - shellOptions = append(shellOptions, shell.WithOnCloseListener(onCloseHandler)) - shellOptions = append(shellOptions, shell.WithFeatures(conf.Features)) - shellOptions = append(shellOptions, shell.WithUpstreamConfig(conf.UpstreamConfig)) - - sh, err := shell.New(connectAsset.Runtime, shellOptions...) - if err != nil { - log.Error().Err(err).Msg("failed to initialize interactive shell") - } + // Create shell theme with custom welcome message if provided + shellTheme := shell.DefaultShellTheme if conf.WelcomeMessage != "" { - sh.Theme.Welcome = conf.WelcomeMessage + // Create a copy with custom welcome message + customTheme := *shellTheme + customTheme.Welcome = conf.WelcomeMessage + shellTheme = &customTheme + } + + // Create and run the new Bubble Tea shell + sh := shell.NewShell( + connectAsset.Runtime, + shell.WithOnClose(onCloseHandler), + shell.WithFeatures(conf.Features), + shell.WithUpstreamConfig(conf.UpstreamConfig), + shell.WithTheme(shellTheme), + ) + + if err := sh.RunWithCommand(conf.Command); err != nil { + if err == shell.ErrNotTTY { + log.Fatal().Msg("shell requires an interactive terminal (TTY)") + } + log.Error().Err(err).Msg("shell error") + return err } - sh.RunInteractive(conf.Command) return nil } diff --git a/cli/shell/completer.go b/cli/shell/completer.go index 4779e88e4b..bd314eda78 100644 --- a/cli/shell/completer.go +++ b/cli/shell/completer.go @@ -4,65 +4,77 @@ package shell import ( - "runtime" + "strings" - "github.com/c-bata/go-prompt" "go.mondoo.com/cnquery/v12" "go.mondoo.com/cnquery/v12/mqlc" "go.mondoo.com/cnquery/v12/providers-sdk/v1/resources" ) -var completerSeparator = string([]byte{'.', ' '}) +// Suggestion represents a completion suggestion for the shell +type Suggestion struct { + Text string // The completion text + Description string // Description shown in popup +} // Completer is an auto-complete helper for the shell type Completer struct { - schema resources.ResourcesSchema - features cnquery.Features - queryPrefix func() string - forceCompletions bool + schema resources.ResourcesSchema + features cnquery.Features + queryPrefix func() string } // NewCompleter creates a new Mondoo completer object func NewCompleter(schema resources.ResourcesSchema, features cnquery.Features, queryPrefix func() string) *Completer { return &Completer{ - schema: schema, - features: features, - queryPrefix: queryPrefix, - forceCompletions: features.IsActive(cnquery.ForceShellCompletion), + schema: schema, + features: features, + queryPrefix: queryPrefix, } } -// CompletePrompt provides suggestions -func (c *Completer) CompletePrompt(doc prompt.Document) []prompt.Suggest { - if runtime.GOOS == "windows" && !c.forceCompletions { +// builtinCommands are shell commands that should appear in completions +var builtinCommands = []Suggestion{ + {Text: "exit", Description: "Exit the shell"}, + {Text: "quit", Description: "Exit the shell"}, + {Text: "help", Description: "Show available resources"}, + {Text: "clear", Description: "Clear the screen"}, +} + +// Complete returns suggestions for the given input text +func (c *Completer) Complete(text string) []Suggestion { + if text == "" { return nil } - if doc.TextBeforeCursor() == "" { - return []prompt.Suggest{} + + var suggestions []Suggestion + + // Check for matching built-in commands first (only at the start of input) + if c.queryPrefix == nil || c.queryPrefix() == "" { + for _, cmd := range builtinCommands { + if strings.HasPrefix(cmd.Text, text) { + suggestions = append(suggestions, cmd) + } + } } + // Get MQL suggestions var query string if c.queryPrefix != nil { query = c.queryPrefix() } - query += doc.TextBeforeCursor() + query += text bundle, _ := mqlc.Compile(query, nil, mqlc.NewConfig(c.schema, c.features)) - if bundle == nil || len(bundle.Suggestions) == 0 { - return []prompt.Suggest{} - } - - res := make([]prompt.Suggest, len(bundle.Suggestions)) - for i := range bundle.Suggestions { - cur := bundle.Suggestions[i] - res[i] = prompt.Suggest{ - Text: cur.Field, - Description: cur.Title, + if bundle != nil && len(bundle.Suggestions) > 0 { + for i := range bundle.Suggestions { + cur := bundle.Suggestions[i] + suggestions = append(suggestions, Suggestion{ + Text: cur.Field, + Description: cur.Title, + }) } } - return res - - // Alternatively we can decide to let prompt filter this list of words for us: - // return prompt.FilterHasPrefix(suggest, doc.GetWordBeforeCursor(), true) + return suggestions } diff --git a/cli/shell/filtered_schema.go b/cli/shell/filtered_schema.go new file mode 100644 index 0000000000..1378f561ab --- /dev/null +++ b/cli/shell/filtered_schema.go @@ -0,0 +1,99 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import ( + "go.mondoo.com/cnquery/v12/providers-sdk/v1/resources" +) + +// FilteredSchema wraps a ResourcesSchema and filters resources to only show +// those from connected providers or cross-provider resources (like core). +type FilteredSchema struct { + schema resources.ResourcesSchema + connectedProviders map[string]struct{} +} + +// Providers that are always available regardless of connection +var alwaysAvailableProviders = []string{ + "go.mondoo.com/cnquery/v9/providers/core", + "go.mondoo.com/cnquery/v9/providers/network", +} + +// NewFilteredSchema creates a new FilteredSchema that only exposes resources +// from the specified providers. The core and network providers are always included +// as they are available regardless of the connection type. +func NewFilteredSchema(schema resources.ResourcesSchema, providerIDs []string) *FilteredSchema { + providers := make(map[string]struct{}, len(providerIDs)+len(alwaysAvailableProviders)) + for _, id := range providerIDs { + providers[id] = struct{}{} + } + // Always include core and network providers + for _, id := range alwaysAvailableProviders { + providers[id] = struct{}{} + } + + return &FilteredSchema{ + schema: schema, + connectedProviders: providers, + } +} + +// Lookup returns the resource info for a given resource name. +// It returns nil if the resource is not from a connected provider. +func (f *FilteredSchema) Lookup(resource string) *resources.ResourceInfo { + info := f.schema.Lookup(resource) + if info == nil { + return nil + } + if !f.isProviderConnected(info.Provider) { + return nil + } + return info +} + +// LookupField returns the resource info and field for a given resource and field name. +func (f *FilteredSchema) LookupField(resource string, field string) (*resources.ResourceInfo, *resources.Field) { + info, fieldInfo := f.schema.LookupField(resource, field) + if info == nil { + return nil, nil + } + if !f.isProviderConnected(info.Provider) { + return nil, nil + } + return info, fieldInfo +} + +// FindField finds a field in a resource, including embedded fields. +func (f *FilteredSchema) FindField(resource *resources.ResourceInfo, field string) (resources.FieldPath, []*resources.Field, bool) { + return f.schema.FindField(resource, field) +} + +// AllResources returns only resources from connected providers. +func (f *FilteredSchema) AllResources() map[string]*resources.ResourceInfo { + all := f.schema.AllResources() + filtered := make(map[string]*resources.ResourceInfo, len(all)) + + for name, info := range all { + if f.isProviderConnected(info.Provider) { + filtered[name] = info + } + } + + return filtered +} + +// AllDependencies returns all provider dependencies. +func (f *FilteredSchema) AllDependencies() map[string]*resources.ProviderInfo { + return f.schema.AllDependencies() +} + +// isProviderConnected checks if a provider is in the connected providers set. +// Empty provider string means cross-provider resource, which is always included. +func (f *FilteredSchema) isProviderConnected(provider string) bool { + if provider == "" { + return true + } + _, ok := f.connectedProviders[provider] + return ok +} diff --git a/cli/shell/highlight.go b/cli/shell/highlight.go new file mode 100644 index 0000000000..98d9ac52dc --- /dev/null +++ b/cli/shell/highlight.go @@ -0,0 +1,198 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import ( + "regexp" + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// MQL syntax highlighting colors +var ( + hlKeyword = lipgloss.NewStyle().Foreground(lipgloss.Color("204")) // Pink for keywords + hlResource = lipgloss.NewStyle().Foreground(lipgloss.Color("39")) // Blue for resources + hlField = lipgloss.NewStyle().Foreground(lipgloss.Color("156")) // Light green for fields + hlString = lipgloss.NewStyle().Foreground(lipgloss.Color("221")) // Yellow for strings + hlNumber = lipgloss.NewStyle().Foreground(lipgloss.Color("141")) // Purple for numbers + hlOperator = lipgloss.NewStyle().Foreground(lipgloss.Color("203")) // Red for operators + hlBracket = lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // Gray for brackets + hlComment = lipgloss.NewStyle().Foreground(lipgloss.Color("242")) // Dark gray for comments +) + +// MQL keywords +var mqlKeywords = map[string]bool{ + "if": true, "else": true, "return": true, "where": true, + "contains": true, "in": true, "not": true, "and": true, "or": true, + "true": true, "false": true, "null": true, "props": true, +} + +// Common MQL resources (top-level) +var mqlResources = map[string]bool{ + "asset": true, "mondoo": true, "users": true, "groups": true, + "packages": true, "services": true, "processes": true, "ports": true, + "files": true, "file": true, "command": true, "parse": true, + "platform": true, "kernel": true, "sshd": true, "os": true, + "aws": true, "gcp": true, "azure": true, "k8s": true, "terraform": true, + "arista": true, "github": true, "gitlab": true, "okta": true, "ms365": true, + "vsphere": true, "docker": true, "container": true, "image": true, +} + +// highlightMQL applies syntax highlighting to MQL code +func highlightMQL(code string) string { + // Handle empty input + if code == "" { + return code + } + + var result strings.Builder + i := 0 + + for i < len(code) { + // Check for comments + if i+1 < len(code) && code[i:i+2] == "//" { + end := strings.Index(code[i:], "\n") + if end == -1 { + result.WriteString(hlComment.Render(code[i:])) + break + } + result.WriteString(hlComment.Render(code[i : i+end])) + i += end + continue + } + + // Check for strings (double quotes) + if code[i] == '"' { + end := i + 1 + for end < len(code) && code[end] != '"' { + if code[end] == '\\' && end+1 < len(code) { + end += 2 + } else { + end++ + } + } + if end < len(code) { + end++ // include closing quote + } + result.WriteString(hlString.Render(code[i:end])) + i = end + continue + } + + // Check for strings (single quotes) + if code[i] == '\'' { + end := i + 1 + for end < len(code) && code[end] != '\'' { + if code[end] == '\\' && end+1 < len(code) { + end += 2 + } else { + end++ + } + } + if end < len(code) { + end++ // include closing quote + } + result.WriteString(hlString.Render(code[i:end])) + i = end + continue + } + + // Check for numbers + if isDigit(code[i]) { + end := i + for end < len(code) && (isDigit(code[end]) || code[end] == '.') { + end++ + } + result.WriteString(hlNumber.Render(code[i:end])) + i = end + continue + } + + // Check for operators + if isOperator(code[i]) { + // Handle multi-character operators + op := string(code[i]) + if i+1 < len(code) { + twoChar := code[i : i+2] + if twoChar == "==" || twoChar == "!=" || twoChar == ">=" || + twoChar == "<=" || twoChar == "&&" || twoChar == "||" || + twoChar == "=~" || twoChar == "!~" { + op = twoChar + } + } + result.WriteString(hlOperator.Render(op)) + i += len(op) + continue + } + + // Check for brackets + if isBracket(code[i]) { + result.WriteString(hlBracket.Render(string(code[i]))) + i++ + continue + } + + // Check for identifiers (words) + if isAlpha(code[i]) || code[i] == '_' { + end := i + for end < len(code) && (isAlphaNum(code[end]) || code[end] == '_') { + end++ + } + word := code[i:end] + + // Check if it's followed by a dot (field access) + isFieldAccess := end < len(code) && code[end] == '.' + + // Check what comes before (to detect if it's a field after a dot) + isAfterDot := i > 0 && code[i-1] == '.' + + if mqlKeywords[word] { + result.WriteString(hlKeyword.Render(word)) + } else if mqlResources[word] && !isAfterDot { + result.WriteString(hlResource.Render(word)) + } else if isAfterDot || isFieldAccess { + result.WriteString(hlField.Render(word)) + } else { + result.WriteString(word) + } + i = end + continue + } + + // Default: pass through + result.WriteByte(code[i]) + i++ + } + + return result.String() +} + +func isDigit(c byte) bool { + return c >= '0' && c <= '9' +} + +func isAlpha(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') +} + +func isAlphaNum(c byte) bool { + return isAlpha(c) || isDigit(c) +} + +func isOperator(c byte) bool { + return c == '=' || c == '!' || c == '<' || c == '>' || + c == '+' || c == '-' || c == '*' || c == '/' || + c == '&' || c == '|' || c == '~' +} + +func isBracket(c byte) bool { + return c == '{' || c == '}' || c == '[' || c == ']' || c == '(' || c == ')' +} + +// Regex for more complex patterns (unused but available) +var ( + _ = regexp.MustCompile(`"[^"]*"`) + _ = regexp.MustCompile(`'[^']*'`) +) diff --git a/cli/shell/keymap.go b/cli/shell/keymap.go new file mode 100644 index 0000000000..62ed7f4b8c --- /dev/null +++ b/cli/shell/keymap.go @@ -0,0 +1,117 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap defines key bindings for the shell +type KeyMap struct { + // Basic navigation + Submit key.Binding + Exit key.Binding + Cancel key.Binding + Clear key.Binding + ShowHelp key.Binding + AssetInfo key.Binding + Newline key.Binding + + // History + HistorySearch key.Binding + + // Completion navigation + NextCompletion key.Binding + PrevCompletion key.Binding + AcceptCompletion key.Binding + DismissPopup key.Binding +} + +// DefaultKeyMap returns the default key bindings +func DefaultKeyMap() KeyMap { + return KeyMap{ + Submit: key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "execute query"), + ), + Exit: key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "exit shell"), + ), + Cancel: key.NewBinding( + key.WithKeys("ctrl+c"), + key.WithHelp("ctrl+c", "cancel/clear input"), + ), + Clear: key.NewBinding( + key.WithKeys("ctrl+l"), + key.WithHelp("ctrl+l", "clear screen"), + ), + ShowHelp: key.NewBinding( + key.WithKeys("?"), + key.WithHelp("?", "show keybindings"), + ), + AssetInfo: key.NewBinding( + key.WithKeys("ctrl+o"), + key.WithHelp("ctrl+o", "show asset info"), + ), + Newline: key.NewBinding( + key.WithKeys("ctrl+j"), + key.WithHelp("ctrl+j", "insert newline"), + ), + HistorySearch: key.NewBinding( + key.WithKeys("ctrl+r"), + key.WithHelp("ctrl+r", "search history"), + ), + NextCompletion: key.NewBinding( + key.WithKeys("down", "tab"), + key.WithHelp("↓/tab", "next suggestion"), + ), + PrevCompletion: key.NewBinding( + key.WithKeys("up", "shift+tab"), + key.WithHelp("↑/shift+tab", "previous suggestion"), + ), + AcceptCompletion: key.NewBinding( + key.WithKeys("enter", "tab"), + key.WithHelp("enter/tab", "accept suggestion"), + ), + DismissPopup: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "dismiss suggestions"), + ), + } +} + +// ShortHelp returns keybindings to show in the mini help view +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Submit, k.Exit, k.ShowHelp} +} + +// FullHelp returns keybindings for the expanded help view +func (k KeyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Submit, k.Exit, k.Cancel, k.Clear, k.ShowHelp}, + {k.AssetInfo, k.Newline, k.HistorySearch}, + {k.NextCompletion, k.PrevCompletion, k.AcceptCompletion, k.DismissPopup}, + } +} + +// FormatFullHelp returns a formatted string of all keybindings +func (k KeyMap) FormatFullHelp() string { + sections := []struct { + title string + bindings []key.Binding + }{ + {"General", []key.Binding{k.Submit, k.Exit, k.Cancel, k.Clear, k.ShowHelp}}, + {"Editing", []key.Binding{k.Newline, k.HistorySearch, k.AssetInfo}}, + {"Suggestions", []key.Binding{k.NextCompletion, k.PrevCompletion, k.AcceptCompletion, k.DismissPopup}}, + } + + var result string + for _, section := range sections { + result += "\n " + section.title + ":\n" + for _, b := range section.bindings { + help := b.Help() + result += " " + help.Key + " - " + help.Desc + "\n" + } + } + return result +} diff --git a/cli/shell/model.go b/cli/shell/model.go new file mode 100644 index 0000000000..0b029c8b29 --- /dev/null +++ b/cli/shell/model.go @@ -0,0 +1,1124 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + + "github.com/charmbracelet/bubbles/spinner" + "github.com/charmbracelet/bubbles/textarea" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/mitchellh/go-homedir" + "github.com/rs/zerolog/log" + "go.mondoo.com/cnquery/v12" + "go.mondoo.com/cnquery/v12/llx" + "go.mondoo.com/cnquery/v12/mql" + "go.mondoo.com/cnquery/v12/mqlc" + "go.mondoo.com/cnquery/v12/mqlc/parser" + "go.mondoo.com/cnquery/v12/utils/stringx" +) + +// ErrNotTTY is returned when the shell is run without a terminal +var ErrNotTTY = errors.New("shell requires an interactive terminal (TTY)") + +// Message types for async operations +type ( + historyLoadedMsg struct { + history []string + } + queryResultMsg struct { + code *llx.CodeBundle + results map[string]*llx.RawResult + err error + } + printOutputMsg struct { + output string + } +) + +// shellModel is the main Bubble Tea model for the interactive shell +type shellModel struct { + // Runtime and configuration + runtime llx.Runtime + theme *ShellTheme + features cnquery.Features + keyMap KeyMap + + // Input handling + input textarea.Model + + // Completion state + completer *Completer + suggestions []Suggestion + selected int + showPopup bool + + // Query state + query string + isMultiline bool + multilineIndent int + + // History + history []string + historyIdx int + historyDraft string + historyPath string + + // History search (ctrl+r) + searchMode bool + searchQuery string + searchMatches []int // indices into history that match + searchIdx int // current index into searchMatches + + // Layout + width int + height int + + // State + ready bool + quitting bool + executing bool + spinner spinner.Model + compileError string // Current compile error (if any) + + // Nyanya animation (easter egg) + nyanya *nyanyaState +} + +// newShellModel creates a new shell model +// connectedProviderIDs can be provided to filter autocomplete suggestions to only +// show resources from connected providers. If nil, all resources are shown. +func newShellModel(runtime llx.Runtime, theme *ShellTheme, features cnquery.Features, initialCmd string, connectedProviderIDs []string) *shellModel { + // Create textarea for input + ta := textarea.New() + ta.Placeholder = "" + ta.CharLimit = 0 // No limit + ta.ShowLineNumbers = false + ta.SetHeight(1) + ta.SetWidth(80) + ta.Focus() + + // Set up dynamic prompt: "> " for first line, ". " for continuation + promptWidth := len(theme.Prefix) + ta.SetPromptFunc(promptWidth, func(lineIdx int) string { + if lineIdx == 0 { + return theme.Prompt.Render(theme.Prefix) + } + return theme.MultilinePrompt.Render(". ") + }) + + // Style the textarea + ta.FocusedStyle.Prompt = lipgloss.NewStyle() // Prompt styling handled by SetPromptFunc + ta.FocusedStyle.Text = theme.InputText + ta.FocusedStyle.CursorLine = lipgloss.NewStyle() + ta.BlurredStyle = ta.FocusedStyle + + // Create completer and set up the schema for the printer + // If connected provider IDs are provided, use a filtered schema to only + // show resources from connected providers in autocomplete + schema := runtime.Schema() + if len(connectedProviderIDs) > 0 { + schema = NewFilteredSchema(schema, connectedProviderIDs) + } + theme.PolicyPrinter.SetSchema(schema) + completer := NewCompleter(schema, features, nil) + + // Create spinner for query execution + sp := spinner.New() + sp.Spinner = spinner.Dot + sp.Style = theme.Spinner + + m := &shellModel{ + runtime: runtime, + theme: theme, + features: features, + keyMap: DefaultKeyMap(), + input: ta, + completer: completer, + suggestions: nil, + selected: 0, + showPopup: false, + history: []string{}, + historyIdx: -1, + width: 80, + height: 24, + spinner: sp, + } + + // Set the query prefix callback for completer + completer.queryPrefix = func() string { + return m.query + } + + // Handle initial command + if initialCmd != "" { + m.input.SetValue(initialCmd) + } + + return m +} + +// Init implements tea.Model +func (m *shellModel) Init() tea.Cmd { + return tea.Batch( + textarea.Blink, + tea.EnableBracketedPaste, + m.loadHistory(), + // Print welcome message + tea.Println(m.theme.Welcome), + ) +} + +// loadHistory loads command history from disk +func (m *shellModel) loadHistory() tea.Cmd { + return func() tea.Msg { + homeDir, err := homedir.Dir() + if err != nil { + log.Warn().Msg("failed to load history") + return historyLoadedMsg{history: []string{}} + } + + historyPath := path.Join(homeDir, ".mondoo_history") + rawHistory, err := os.ReadFile(historyPath) + if err != nil { + return historyLoadedMsg{history: []string{}} + } + + history := strings.Split(string(rawHistory), "\n") + // Filter empty lines + filtered := make([]string, 0, len(history)) + for _, h := range history { + if h != "" { + filtered = append(filtered, h) + } + } + + return historyLoadedMsg{history: filtered} + } +} + +// saveHistory saves command history to disk +func (m *shellModel) saveHistory() { + if m.historyPath == "" { + homeDir, _ := homedir.Dir() + m.historyPath = path.Join(homeDir, ".mondoo_history") + } + + rawHistory := strings.Join(m.history, "\n") + if err := os.WriteFile(m.historyPath, []byte(rawHistory), 0o640); err != nil { + log.Error().Err(err).Msg("failed to save history") + } +} + +// Update implements tea.Model +func (m *shellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + // Update textarea width (leave space for prompt) + promptLen := len(m.theme.Prefix) + inputWidth := msg.Width - promptLen - 2 + if inputWidth < 20 { + inputWidth = 20 + } + m.input.SetWidth(inputWidth) + // Recalculate height in case line wrapping changed + m.updateInputHeight() + m.ready = true + return m, nil + + case historyLoadedMsg: + m.history = msg.history + m.historyIdx = len(m.history) + homeDir, _ := homedir.Dir() + m.historyPath = path.Join(homeDir, ".mondoo_history") + return m, nil + + case queryResultMsg: + // Query finished executing + m.executing = false + // Print results directly to terminal (outside of Bubble Tea's view) + if msg.err != nil { + output := m.theme.ErrorText("failed to compile: " + msg.err.Error()) + if msg.code != nil && msg.code.Suggestions != nil { + output += "\n" + m.formatSuggestions(msg.code.Suggestions) + } + return m, tea.Println(output) + } + output := m.formatResults(msg.code, msg.results) + return m, tea.Println(output) + + case spinner.TickMsg: + var cmd tea.Cmd + m.spinner, cmd = m.spinner.Update(msg) + return m, cmd + + case nyanyaTickMsg: + // Advance the nyanya animation + if m.nyanya != nil { + m.nyanya.currentFrame++ + if m.nyanya.currentFrame >= len(m.nyanya.frames) { + m.nyanya.currentFrame = 0 + m.nyanya.loopCount++ + if m.nyanya.loopCount >= m.nyanya.maxLoops { + // Animation complete + m.nyanya = nil + return m, nil + } + } + return m, nyanyaTick() + } + return m, nil + + case printOutputMsg: + // Print output directly to terminal + return m, tea.Println(msg.output) + + case tea.KeyMsg: + return m.handleKeyMsg(msg) + } + + return m, tea.Batch(cmds...) +} + +// handleKeyMsg processes keyboard input +func (m *shellModel) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Handle nyanya animation - any key exits + if m.nyanya != nil { + m.nyanya = nil + return m, nil + } + + // Handle history search mode (ctrl+r) + if m.searchMode { + return m.handleSearchKey(msg) + } + + // Handle pasted content - let textarea handle it but adjust height after + if msg.Paste { + m.showPopup = false + m.suggestions = nil + // Let textarea handle the paste + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.updateInputHeight() + m.updateCompletions() + return m, cmd + } + + // Handle completion popup if visible (but not during paste) + if m.showPopup && len(m.suggestions) > 0 { + switch msg.String() { + case "down", "tab": + m.selected = (m.selected + 1) % len(m.suggestions) + return m, nil + case "up", "shift+tab": + m.selected-- + if m.selected < 0 { + m.selected = len(m.suggestions) - 1 + } + return m, nil + case "enter": + // Accept the selected completion + return m.acceptCompletion() + case "esc": + m.showPopup = false + m.suggestions = nil + return m, nil + } + } + + // DEBUG: see key name (uncomment to debug) + // return m, tea.Println(fmt.Sprintf("Key: [%s] Paste: %v Runes: %d", msg.String(), msg.Paste, len(msg.Runes))) + + switch msg.String() { + case "ctrl+d": + m.quitting = true + return m, tea.Quit + + case "ctrl+c": + // If there's any input or we're in multiline mode, cancel it + if m.input.Value() != "" || m.isMultiline { + m.isMultiline = false + m.query = "" + m.input.SetValue("") + m.input.SetHeight(1) + m.showPopup = false + m.suggestions = nil + // Print ^C to show the interrupt + return m, tea.Println("^C") + } + // No input - quit the shell + m.quitting = true + return m, tea.Sequence( + tea.Println("^C"), + tea.Quit, + ) + + case "ctrl+l": + // Clear screen using ANSI escape codes + return m, tea.Println("\033[2J\033[H") + + case "ctrl+o": + // Show asset information + return m, m.showAssetInfo() + + case "?": + // Show keybindings help (only when input is empty to avoid interfering with queries) + if m.input.Value() == "" { + helpText := m.theme.SecondaryText("Keyboard Shortcuts:") + m.keyMap.FormatFullHelp() + return m, tea.Println(helpText) + } + + case "ctrl+r": + // Enter history search mode + if len(m.history) > 0 { + m.searchMode = true + m.searchQuery = "" + m.searchMatches = nil + m.searchIdx = 0 + // Save current input as draft + m.historyDraft = m.input.Value() + } + return m, nil + + case "ctrl+j": + // Insert a newline for manual multiline input + m.showPopup = false + m.suggestions = nil + m.input.InsertString("\n") + m.updateInputHeight() + return m, nil + + case "enter": + return m.handleSubmit() + } + + // Let textarea handle all other keys + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.updateInputHeight() + m.updateCompletions() + return m, cmd +} + +// formatInputWithPrompts formats input with proper prompts and syntax highlighting for each line +func (m *shellModel) formatInputWithPrompts(input string) string { + lines := strings.Split(input, "\n") + + var result strings.Builder + for i, line := range lines { + if i == 0 { + result.WriteString(m.theme.Prompt.Render(m.theme.Prefix)) + } else { + result.WriteString("\n") + result.WriteString(m.theme.MultilinePrompt.Render(". ")) + } + // Apply syntax highlighting to the code + result.WriteString(highlightMQL(line)) + } + return result.String() +} + +// handleSubmit processes the enter key +func (m *shellModel) handleSubmit() (tea.Model, tea.Cmd) { + input := strings.TrimSpace(m.input.Value()) + + // Handle empty input + if input == "" && !m.isMultiline { + return m, nil + } + + // Echo the prompt and input so it stays in terminal history + echoInput := m.formatInputWithPrompts(input) + + // Check for built-in commands (only when not in multiline mode) + if !m.isMultiline { + switch input { + case "exit", "quit": + m.quitting = true + return m, tea.Sequence( + tea.Println(echoInput), + tea.Quit, + ) + case "clear": + m.input.SetValue("") + // Clear screen using ANSI escape codes + return m, tea.Println("\033[2J\033[H") + case "help": + output := m.listResources("") + m.input.SetValue("") + m.addToHistory(input) + return m, tea.Sequence( + tea.Println(echoInput), + tea.Println(output), + ) + case "nyanya": + m.input.SetValue("") + // Initialize and start the nyancat animation + m.nyanya = initNyanya() + if m.nyanya == nil { + return m, tea.Println(m.theme.ErrorText("Failed to initialize nyanya animation")) + } + return m, tea.Batch( + tea.Println(echoInput), + nyanyaTick(), + ) + } + + // Check for "help " + if strings.HasPrefix(input, "help ") { + resource := strings.TrimPrefix(input, "help ") + output := m.listResources(resource) + m.input.SetValue("") + m.addToHistory(input) + return m, tea.Sequence( + tea.Println(echoInput), + tea.Println(output), + ) + } + } + + // Execute as MQL query + return m.executeQuery(input) +} + +// executeQuery compiles and runs an MQL query +func (m *shellModel) executeQuery(input string) (tea.Model, tea.Cmd) { + // Echo the current line input with proper prompts + echoInput := m.formatInputWithPrompts(input) + + // Accumulate query for multiline + m.query += " " + input + + // Try to compile + code, err := mqlc.Compile(m.query, nil, mqlc.NewConfig(m.runtime.Schema(), m.features)) + if err != nil { + if e, ok := err.(*parser.ErrIncomplete); ok { + // Incomplete query - enter multiline mode + m.isMultiline = true + m.multilineIndent = e.Indent + m.input.SetValue("") + m.updatePrompt() + // Echo the line for multiline continuation + return m, tea.Println(echoInput) + } + } + + // Query is complete (or has error) - execute it + cleanCommand := m.query + if code != nil { + cleanCommand = code.Source + } + + m.addToHistory(strings.TrimSpace(cleanCommand)) + + // Clear input and reset state + m.input.SetValue("") + m.input.SetHeight(1) + m.isMultiline = false + m.executing = true + + // Execute the query + queryToRun := m.query + m.query = "" + + // Echo the input, start spinner, then execute and return results + return m, tea.Batch( + tea.Println(echoInput), + m.spinner.Tick, + func() tea.Msg { + code, err := mqlc.Compile(queryToRun, nil, mqlc.NewConfig(m.runtime.Schema(), m.features)) + if err != nil { + return queryResultMsg{code: code, err: err} + } + + results, err := mql.ExecuteCode(m.runtime, code, nil, m.features) + return queryResultMsg{code: code, results: results, err: err} + }, + ) +} + +// updatePrompt updates the input prompt based on multiline state +func (m *shellModel) updatePrompt() { + // The prompt is handled by SetPromptFunc in newShellModel + // This function is kept for compatibility but doesn't need to do anything +} + +// addToHistory adds a command to history +func (m *shellModel) addToHistory(cmd string) { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return + } + // Don't add duplicates + if len(m.history) > 0 && m.history[len(m.history)-1] == cmd { + return + } + m.history = append(m.history, cmd) + m.historyIdx = len(m.history) +} + +// calculateInputHeight returns the height needed for the textarea based on content +func (m *shellModel) calculateInputHeight() int { + lines := strings.Count(m.input.Value(), "\n") + 1 + // Add extra line for cursor when at end of line with newline + if strings.HasSuffix(m.input.Value(), "\n") { + lines++ + } + if lines < 1 { + lines = 1 + } + return lines +} + +// updateInputHeight adjusts textarea height to fit content +func (m *shellModel) updateInputHeight() { + height := m.calculateInputHeight() + if height != m.input.Height() { + m.input.SetHeight(height) + } +} + +// handleSearchKey processes key input during history search mode +func (m *shellModel) handleSearchKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "ctrl+r": + // Find next match (go backwards in history) + if len(m.searchMatches) > 0 { + m.searchIdx++ + if m.searchIdx >= len(m.searchMatches) { + m.searchIdx = 0 // wrap around + } + m.applySearchMatch() + } + return m, nil + + case "ctrl+c", "esc": + // Cancel search, restore original input + m.searchMode = false + m.input.SetValue(m.historyDraft) + m.updateInputHeight() + return m, nil + + case "enter": + // Accept current match and exit search mode + m.searchMode = false + // Keep the current input value (already set by search) + return m, nil + + case "backspace": + // Remove last character from search query + if len(m.searchQuery) > 0 { + m.searchQuery = m.searchQuery[:len(m.searchQuery)-1] + m.updateSearchMatches() + } + return m, nil + + case "ctrl+g": + // Abort search (like in emacs) + m.searchMode = false + m.input.SetValue(m.historyDraft) + m.updateInputHeight() + return m, nil + + default: + // Add typed characters to search query + if len(msg.Runes) > 0 { + for _, r := range msg.Runes { + m.searchQuery += string(r) + } + m.updateSearchMatches() + } + return m, nil + } +} + +// updateSearchMatches finds all history entries matching the search query +func (m *shellModel) updateSearchMatches() { + m.searchMatches = nil + m.searchIdx = 0 + + if m.searchQuery == "" { + m.input.SetValue("") + m.updateInputHeight() + return + } + + query := strings.ToLower(m.searchQuery) + + // Search backwards through history (most recent first) + for i := len(m.history) - 1; i >= 0; i-- { + if strings.Contains(strings.ToLower(m.history[i]), query) { + m.searchMatches = append(m.searchMatches, i) + } + } + + m.applySearchMatch() +} + +// applySearchMatch applies the current search match to the input +func (m *shellModel) applySearchMatch() { + if len(m.searchMatches) == 0 { + m.input.SetValue("") + m.updateInputHeight() + return + } + + idx := m.searchMatches[m.searchIdx] + m.input.SetValue(m.history[idx]) + m.historyIdx = idx + m.updateInputHeight() + m.input.CursorStart() +} + +// isBuiltinCommand checks if the input is a built-in shell command +func isBuiltinCommand(input string) bool { + trimmed := strings.TrimSpace(input) + switch { + case trimmed == "exit", trimmed == "quit", trimmed == "clear", trimmed == "help", trimmed == "nyanya": + return true + case strings.HasPrefix(trimmed, "help "): + return true + } + return false +} + +// updateCompletions fetches new completions based on current input +func (m *shellModel) updateCompletions() { + input := m.input.Value() + if input == "" { + m.showPopup = false + m.suggestions = nil + m.compileError = "" + return + } + + // Get completions + suggestions := m.completer.Complete(input) + if len(suggestions) > 0 { + m.suggestions = suggestions + m.selected = 0 + m.showPopup = true + } else { + m.showPopup = false + m.suggestions = nil + } + + // Skip compile error checking for built-in shell commands + if isBuiltinCommand(input) { + m.compileError = "" + return + } + + // Check for compile errors (for inline feedback) + fullQuery := m.query + " " + input + _, err := mqlc.Compile(fullQuery, nil, mqlc.NewConfig(m.runtime.Schema(), m.features)) + if err != nil { + // Ignore incomplete errors - those are expected for multi-line + if _, ok := err.(*parser.ErrIncomplete); !ok { + m.compileError = err.Error() + } else { + m.compileError = "" + } + } else { + m.compileError = "" + } +} + +// acceptCompletion inserts the selected completion +func (m *shellModel) acceptCompletion() (tea.Model, tea.Cmd) { + if m.selected >= 0 && m.selected < len(m.suggestions) { + suggestion := m.suggestions[m.selected] + + // Get current input and find the word to replace + input := m.input.Value() + + // Find the start of the current word (after last separator) + lastDot := strings.LastIndex(input, ".") + lastSpace := strings.LastIndex(input, " ") + wordStart := lastDot + if lastSpace > lastDot { + wordStart = lastSpace + } + + var newValue string + if wordStart >= 0 { + newValue = input[:wordStart+1] + suggestion.Text + } else { + newValue = suggestion.Text + } + + m.input.SetValue(newValue) + } + + m.showPopup = false + m.suggestions = nil + + // Recompile the query to update error display after completion + m.recompileForErrors() + + return m, nil +} + +// recompileForErrors recompiles the current query to update the error display +// without triggering new completion suggestions +func (m *shellModel) recompileForErrors() { + input := m.input.Value() + if input == "" { + m.compileError = "" + return + } + + // Skip compile error checking for built-in shell commands + if isBuiltinCommand(input) { + m.compileError = "" + return + } + + // Check for compile errors + fullQuery := m.query + " " + input + _, err := mqlc.Compile(fullQuery, nil, mqlc.NewConfig(m.runtime.Schema(), m.features)) + if err != nil { + // Ignore incomplete errors - those are expected for multi-line + if _, ok := err.(*parser.ErrIncomplete); !ok { + m.compileError = err.Error() + } else { + m.compileError = "" + } + } else { + m.compileError = "" + } +} + +// View implements tea.Model +func (m *shellModel) View() string { + if !m.ready { + return "Loading..." + } + + // Render nyanya animation if active (full screen modal) + if m.nyanya != nil { + return renderNyanya(m.nyanya, m.width, m.height) + } + + var b strings.Builder + + // Show spinner when executing, otherwise show input + if m.executing { + b.WriteString(m.spinner.View()) + b.WriteString(" Executing query...") + } else if m.searchMode { + // Show search interface + b.WriteString(m.renderSearchView()) + } else { + // Render textarea input + b.WriteString(m.input.View()) + + // Completion popup + if m.showPopup && len(m.suggestions) > 0 { + b.WriteString("\n") + b.WriteString(m.renderCompletionPopup()) + } + + // Show compile error if any + if m.compileError != "" && !m.showPopup { + b.WriteString("\n") + // Truncate long error messages + errMsg := m.compileError + maxLen := m.width - 4 + if maxLen > 0 && len(errMsg) > maxLen { + errMsg = errMsg[:maxLen-3] + "..." + } + b.WriteString(m.theme.Error.Render("⚠ " + errMsg)) + } + } + + // Help bar at the bottom (with empty line separator) + b.WriteString("\n\n") + b.WriteString(m.renderHelpBar()) + + return b.String() +} + +// showAssetInfo executes a query to display asset information +func (m *shellModel) showAssetInfo() tea.Cmd { + return func() tea.Msg { + // Query for asset information using proper MQL syntax + query := "asset.name asset.platform asset.version" + code, err := mqlc.Compile(query, nil, mqlc.NewConfig(m.runtime.Schema(), m.features)) + if err != nil { + return printOutputMsg{output: m.theme.ErrorText("Failed to get asset info: " + err.Error())} + } + + results, err := mql.ExecuteCode(m.runtime, code, nil, m.features) + if err != nil { + return printOutputMsg{output: m.theme.ErrorText("Failed to get asset info: " + err.Error())} + } + + // Format output nicely + var lines []string + lines = append(lines, m.theme.SecondaryText("Asset Information")) + + // Extract values from results in order + for _, entry := range code.CodeV2.Entrypoints() { + checksum := code.CodeV2.Checksums[entry] + if result, ok := results[checksum]; ok && result.Data != nil { + label := code.Labels.Labels[checksum] + value := result.Data.Value + lines = append(lines, fmt.Sprintf(" %s: %v", m.theme.HelpKey.Render(label), value)) + } + } + + return printOutputMsg{output: strings.Join(lines, "\n")} + } +} + +// renderSearchView renders the history search interface +func (m *shellModel) renderSearchView() string { + var b strings.Builder + + // Show the search prompt + searchPrompt := m.theme.Secondary.Render("(reverse-i-search)`") + + m.theme.HelpKey.Render(m.searchQuery) + + m.theme.Secondary.Render("': ") + + b.WriteString(searchPrompt) + + // Show current match or empty + if len(m.searchMatches) > 0 { + // Show the matched command (first line only for preview) + match := m.history[m.searchMatches[m.searchIdx]] + lines := strings.Split(match, "\n") + preview := lines[0] + if len(lines) > 1 { + preview += m.theme.Disabled.Render(" ...") + } + b.WriteString(preview) + } else if m.searchQuery != "" { + b.WriteString(m.theme.Disabled.Render("(no match)")) + } + + // Show match count + if len(m.searchMatches) > 0 { + b.WriteString("\n") + b.WriteString(m.theme.HelpText.Render(fmt.Sprintf(" [%d/%d matches]", m.searchIdx+1, len(m.searchMatches)))) + } + + return b.String() +} + +// renderHelpBar renders the help bar with available key bindings +func (m *shellModel) renderHelpBar() string { + var items []string + + if m.searchMode { + items = []string{ + m.theme.HelpKey.Render("ctrl+r") + m.theme.HelpText.Render(" next"), + m.theme.HelpKey.Render("enter") + m.theme.HelpText.Render(" select"), + m.theme.HelpKey.Render("esc") + m.theme.HelpText.Render(" cancel"), + } + } else if m.showPopup { + items = []string{ + m.theme.HelpKey.Render("↑↓") + m.theme.HelpText.Render(" navigate"), + m.theme.HelpKey.Render("tab") + m.theme.HelpText.Render(" select"), + m.theme.HelpKey.Render("esc") + m.theme.HelpText.Render(" dismiss"), + } + } else if m.executing { + items = []string{ + m.theme.HelpText.Render("query running..."), + } + } else { + items = []string{ + m.theme.HelpKey.Render("enter") + m.theme.HelpText.Render(" run"), + m.theme.HelpKey.Render("ctrl+r") + m.theme.HelpText.Render(" search"), + m.theme.HelpKey.Render("ctrl+d") + m.theme.HelpText.Render(" exit"), + m.theme.HelpKey.Render("?") + m.theme.HelpText.Render(" help"), + } + } + + return strings.Join(items, m.theme.HelpText.Render(" • ")) +} + +// renderCompletionPopup renders the completion suggestions +func (m *shellModel) renderCompletionPopup() string { + if len(m.suggestions) == 0 { + return "" + } + + maxItems := 10 + if len(m.suggestions) < maxItems { + maxItems = len(m.suggestions) + } + + // Calculate scroll offset + startIdx := 0 + if m.selected >= maxItems { + startIdx = m.selected - maxItems + 1 + } + + // Calculate available width and column sizes + availableWidth := m.width + if availableWidth < 40 { + availableWidth = 80 // fallback + } + + // Reserve space for: padding (4), separator (3), description (min 20) + minDescWidth := 20 + maxNameWidth := availableWidth - minDescWidth - 7 + + // Cap name column width + if maxNameWidth > 40 { + maxNameWidth = 40 + } + if maxNameWidth < 15 { + maxNameWidth = 15 + } + + // Find the longest name in visible items (for alignment) + nameWidth := 0 + for i := startIdx; i < startIdx+maxItems && i < len(m.suggestions); i++ { + nameLen := len(m.suggestions[i].Text) + if nameLen > nameWidth { + nameWidth = nameLen + } + } + // Clamp to maxNameWidth + if nameWidth > maxNameWidth { + nameWidth = maxNameWidth + } + if nameWidth < 10 { + nameWidth = 10 + } + + // Calculate description width + descWidth := availableWidth - nameWidth - 7 + if descWidth < minDescWidth { + descWidth = minDescWidth + } + if descWidth > 50 { + descWidth = 50 + } + + var rows []string + for i := startIdx; i < startIdx+maxItems && i < len(m.suggestions); i++ { + s := m.suggestions[i] + + var suggStyle, descStyle lipgloss.Style + if i == m.selected { + suggStyle = m.theme.SuggestionSelected + descStyle = m.theme.DescriptionSelected + } else { + suggStyle = m.theme.SuggestionNormal + descStyle = m.theme.DescriptionNormal + } + + // Truncate name if needed + name := s.Text + if len(name) > nameWidth { + name = name[:nameWidth-1] + "…" + } + + // Truncate description if needed + desc := s.Description + if len(desc) > descWidth { + desc = desc[:descWidth-1] + "…" + } + + // Format with proper alignment + nameFormatted := fmt.Sprintf("%-*s", nameWidth, name) + descFormatted := fmt.Sprintf("%-*s", descWidth, desc) + + row := suggStyle.Render(nameFormatted) + " " + descStyle.Render(descFormatted) + rows = append(rows, row) + } + + // Add scroll indicator if needed + if len(m.suggestions) > maxItems { + indicator := fmt.Sprintf(" ↑↓ %d/%d", m.selected+1, len(m.suggestions)) + rows = append(rows, m.theme.ScrollIndicator.Render(indicator)) + } + + return strings.Join(rows, "\n") +} + +// formatResults formats query results for display +func (m *shellModel) formatResults(code *llx.CodeBundle, results map[string]*llx.RawResult) string { + result := m.theme.PolicyPrinter.Results(code, results) + + // Apply max lines limit (1024 by default) + result = stringx.MaxLines(1024, result) + + return result +} + +// formatSuggestions formats compiler suggestions for display +func (m *shellModel) formatSuggestions(suggestions []*llx.Documentation) string { + var b strings.Builder + b.WriteString(m.theme.SecondaryText("\nsuggestions:\n")) + for _, s := range suggestions { + b.WriteString("- " + s.Field + ": " + s.Title + "\n") + } + return b.String() +} + +// listResources lists available resources +func (m *shellModel) listResources(filter string) string { + resources := m.runtime.Schema().AllResources() + + var keys []string + for k := range resources { + if filter == "" || strings.HasPrefix(k, filter) { + keys = append(keys, k) + } + } + + if len(keys) == 0 { + return "No resources found" + } + + // Sort keys + sortedKeys := make([]string, len(keys)) + copy(sortedKeys, keys) + for i := 0; i < len(sortedKeys); i++ { + for j := i + 1; j < len(sortedKeys); j++ { + if sortedKeys[i] > sortedKeys[j] { + sortedKeys[i], sortedKeys[j] = sortedKeys[j], sortedKeys[i] + } + } + } + + var b strings.Builder + for _, k := range sortedKeys { + resource := resources[k] + b.WriteString(m.theme.SecondaryText(resource.Name)) + b.WriteString(": ") + b.WriteString(resource.Title) + b.WriteString("\n") + + // If filtering to a specific resource, show its fields + if filter != "" && k == filter { + for _, field := range resource.Fields { + if field.IsPrivate { + continue + } + b.WriteString(" ") + b.WriteString(m.theme.SecondaryText(field.Name)) + b.WriteString(": ") + b.WriteString(field.Title) + b.WriteString("\n") + } + } + } + + return b.String() +} diff --git a/cli/shell/nyago.go b/cli/shell/nyago.go index ebc3bfd868..0b9695150c 100644 --- a/cli/shell/nyago.go +++ b/cli/shell/nyago.go @@ -13,19 +13,51 @@ import ( "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/pierrec/lz4/v4" ) -func nyago(width, height int) { +// nyanyaState holds the animation state +type nyanyaState struct { + frames [][]string + currentFrame int + loopCount int + maxLoops int +} + +// nyanyaTickMsg is sent when it's time to advance the frame +type nyanyaTickMsg time.Time + +// nyanyaColors maps characters to 256-color codes +var nyanyaColors = map[string]string{ + "'": "0", // outline + ".": "15", // white + ",": "234", // bg + ">": "198", // lightred (rainbow 1) + "&": "211", // lightorange (rainbow 2) + "+": "222", // lightyellow (rainbow 3) + "#": "86", // lightgreen (rainbow 4) + "=": "45", // lightblue (rainbow 5) + ";": "32", // lightpurple (rainbow 6) + "@": "224", // outer body + "$": "217", // inner body + "-": "204", // dots on the cat + "%": "210", // cheeks + "*": "248", // grey +} + +// initNyanya initializes the nyanya animation state +func initNyanya() *nyanyaState { cdec, err := base64.StdEncoding.DecodeString(c) if err != nil { - return + return nil } reader := lz4.NewReader(bytes.NewReader(cdec)) all := make([]byte, 50000) if _, err := reader.Read(all); err != nil && err != io.EOF { - return + return nil } framesRaw := strings.Split(string(all), "z") @@ -34,71 +66,101 @@ func nyago(width, height int) { frames[i] = strings.Split(framesRaw[i], "\n") } - fmt.Printf("%+v\n", frames) - - stop := make(chan struct{}, 1) - captureSIGINTonce(stop) - - colors := map[string]string{ - "'": "0", // outline - ".": "15", // white - ",": "234", // bg - ">": "198", // lightred (rainbow 1) - "&": "211", // lightorange (rainbow 2) - "+": "222", // lightyellow (rainbow 3) - "#": "86", // lightgreen (rainbow 4) - "=": "45", // lightblue (rainbow 5) - ";": "32", // lightpurple (rainbow 6) - "@": "224", // outer body - "$": "217", // inner body - "-": "204", // dots on the cat - "%": "210", // cheeks - "*": "248", // grey + return &nyanyaState{ + frames: frames, + maxLoops: 3, + } +} + +// nyanyaTick returns a command that ticks the animation +func nyanyaTick() tea.Cmd { + return tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { + return nyanyaTickMsg(t) + }) +} + +// renderNyanya renders the current frame centered on screen +func renderNyanya(state *nyanyaState, width, height int) string { + if state == nil || len(state.frames) == 0 { + return "" } - fmt.Print("\033[H\033[2J\033[?25l") - const outputChar = " " + frame := state.frames[state.currentFrame] + + // Build the frame with colors, filtering empty lines + var frameLines []string + for _, line := range frame { + if len(line) == 0 { + continue // Skip empty lines + } + var lineBuilder strings.Builder + for _, char := range line { + colorCode := nyanyaColors[string(char)] + if colorCode == "" { + colorCode = "234" // default bg + } + // Use ANSI 256-color background + lineBuilder.WriteString(fmt.Sprintf("\033[48;5;%sm \033[0m", colorCode)) + } + frameLines = append(frameLines, lineBuilder.String()) + } - y0 := 0 - y1 := len(frames[0]) + frameHeight := len(frameLines) + frameWidth := 0 + if frameHeight > 0 && len(frame) > 0 { + // Find the first non-empty line to calculate width + for _, line := range frame { + if len(line) > 0 { + frameWidth = len(line) * 2 // Each char becomes 2 spaces wide + break + } + } + } - x0 := 0 - x1 := len(frames[0][0]) + // Add instruction at bottom + instruction := lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Render("Press any key to exit") - if y1 > height { - y0 = (y1 - height) / 2 - y1 = y0 + height + // Calculate vertical positioning + topPadding := (height - frameHeight - 2) / 2 + if topPadding < 0 { + topPadding = 0 } - if x1 > width { - x0 = (x1 - width) / 2 - x1 = x0 + width + // Center each frame line horizontally + leftPadding := (width - frameWidth) / 2 + if leftPadding < 0 { + leftPadding = 0 } + padding := strings.Repeat(" ", leftPadding) - ticker := time.NewTicker(90 * time.Millisecond) - defer func() { ticker.Stop() }() - - for i := 0; i < 3; i++ { - for _, frame := range frames { - // Print the next frame - for _, line := range frame[y0:y1] { - for _, char := range line[x0:x1] { - fmt.Printf("\033[48;5;%sm%s", colors[string(char)], outputChar) - } - fmt.Println("\033[m") - } + var result strings.Builder - // Reset the frame and sleep - fmt.Print("\033[H") - time.Sleep(90 * time.Millisecond) + // Add top padding (empty lines) + for i := 0; i < topPadding; i++ { + result.WriteString("\n") + } - select { - case <-stop: - return - case <-ticker.C: - } + // Add frame lines (no extra newline after the last one) + for i, line := range frameLines { + result.WriteString(padding + line) + if i < len(frameLines)-1 { + result.WriteString("\n") } } + + // Add spacing before instruction + result.WriteString("\n\n") + + // Add centered instruction + instructionPadding := (width - lipgloss.Width(instruction)) / 2 + if instructionPadding < 0 { + instructionPadding = 0 + } + result.WriteString(strings.Repeat(" ", instructionPadding) + instruction) + + return result.String() } const c = "BCJNGGRAp7kIAAAfLAEAEh8uGgAGHwpAABQfLkEABg+CAFMvLCxBAMcfLkEAbg/DACwAHwIPRQHwD0EA/zYfLkEALA6uBA+CAB4PRQGGGicBAA9BAAkSPgEABBMAAg8AKidAAQAPQgAFLwo+AQADAEAAFiQBAA5CAA9BABABOgARLQMAAEIAD0EABR8mAQADIydAOgBAJCQnJwoAX0AnLCcnQQAcA0AAMCoqJ4MAECcJAA9BAAMSKwEABTwAVScnKysnwgAhJypCAD9AJypBAAMcKwEAAGkAKScrggAAEgAvJypBABUCQgALQQADAQAPQQAEEiMBAAVBAAFCAANBABgtQAAPQgABLgojAQABQgA1QCQtgQAzLicqBgAPQQATJyMnwwABPAEAOwAvKidBAAYSPQEABD8AAAwAEidJAgGFASQlJcEALyUlQQAAHz0BAAMGywICgwAvJycIAgVCPT09OwEAEy48AAAMACYnJ00DHyeFAQk/LAo7AQABBnYADtMDD0EAFUMnJywncwAACAAOwAAHQQADhwkAxgAAPgADTQQWLD0DCQ0AD1kGiR8ungcuD0EA/+kfLggC/9kfLkEAKyAuLgMAD4IALQ9BAGwPRQELL3oKggArDoQAD0UBHC8sLIIAEg9BACwPDgPFHy5BACsWLkMAD8MAYxcuBgAPRQEmDwQBbg+GAQIPpAgaD0EA//9aD0IQ+jIkJydCEA75DA9CEBIDQxAeQHsND0IQBQKEDwVCEANDEAGDEC8qJwEQESYrJ0EADkMQD0IQEgbEEA5DEA9CEBEXKsAPLi0kQxAPARANAqwABEIQDkMQD4MQFg9DEBMPQhADFidCEA5DEA9CEB0PQxAMB0IQFz1CEBc7QhAOQxAPARAPAgIQD0MQAA9BABoHQhAPQxAIAkIQAeoGBD0ABTUQGCxCEA5DEA8cB///mQ/sCREPSQL//xQiLi4EAA8EAdAfeoQARi4uLnkgDwYBXh8uzQLFHy7DAKweLsUAD4YB/2EP4hfCDyMYbAD4BA9FAbEFOhAbPkkQDoMQD0IQFQ+DEF0PARAED4MQWAU6EBkrSRAPgxAXDkIQD4MQLx8rgxAaBToQGSNJEA6DEA+EIBgBBBAPgxAoAYMgD4MQFyc9PXsgAUkQBMQQD4MQFA8+EAEfJ4MQGQU6EBk7SRAPgxAXDgEQD4MQHR8uQxAED4MQFTcuLC45EAVJEAHFEB4qhBAPmCUXDcYgDoQQD3cN/7EPZhkxD+wacA+GAf9tD0EADR8uggBsDtcMD0UBYR8uRQEsD0IQbA5FAQ9LAmIPWwb/ih8u4hf//wQfLkEAKx8uzSJxAwYAD0UBJw8EAW4FhgEPQhD//00fJ0IQLS8qKkIQKR8jQhAtLycqQhAtHydCECwPxSBdBXwgCUIQCcUgDggxDwEQEAnFIB8sxSD/vx8uahosD8MAbQ7PDA9uGyAPBAFuHy6KAswfLoIAawhfEg9FAd0PQhA2DkUBD/kN/8MPEENwD0IQ/78fLsMArA5lCA+GAbQPxjACHz5CEPoOSUEPxjAYDklBD0IQEw9JQSoYK4pBDklBDwhBDwN2Dg9JQRYPxTABD0lBLAFpDw9JQRoOxjAPSUEGDwhBGg9JQRcPxjAAHydJQQEPARAYHydJQSwOSEEPSUELCDsQAcYwCnQgCY8gD5IEFAp0IA5IQQ8oCv/mD+cJbgFCLR8uRQFtDywL/5IP80oHD8Yw/xofLtkF/5APTUIMDxBD/xUPQQD//wAfLgQBtg9CEOERJ78PDkpRD0IQFQAxDR4tSlEPQhAVDwEQIQgIQR8tSlEjDkIQDkpRDwhBFR8kARAhCAIgHi1KUQ+LURcPSlEoCcMAD0pRJggJIg5KUQ9CEBcfJAEQCi8uCoMwBgdIAw+SAwgfLkIQFg4BEA9CEDAO2jUPQhAJDx83hg9dB/9THy5BAGwOqAoPwwBgD+cJ/1YPMVsvHy6CAC0PQhCvHy76XHAPDgP/Ch8uDEJTDxBD/w0fLgEQ/8EPDiMvHy6CAGwPSlFJD8YwBQ+MYf8bDsYwXycnJyYmQyAqD4xhXA/GMAAPjGGdD8YwAAKKQQ+MYVYPCEEDD0MgDx8ujGErB9Y0D4xhEg5EIA/GMAwDOAMOAhAP2jW7D98H/5YPpgktDhEODygKXw9BAP//nx96yQH/hh8uDEJcDxBD/wQfLtUE/4QfLkEAbA6oGA/DAGAPQhD/Mg+MYU0PCEEED4xhmQ5CEA+MYZ4PQhACD4xhWg9KUQUPx0AGHy6MYX4PCEEDD4xhAg/aNbcPNg3/2h8ufw///14vLi5CEP8yDwiAsg/JATofLk8DZA8QQ/sfLlcF/0QfLkEAbg/DACsBOhAfLkUBxA8IQf8jDoMQD8YwEQ9KUZkOxjACxA8PjGGYDsYwD4xhXQ/OcQUPSlFtLzsngxAZCDsQBc5xDIxhDkkiDpIED4xhIg/sGv/JD0EAag7rGg+GAf9iD0EAVg/Bfm0IqwEPlGNqHy5FATMfegiA6w9bBvcPEEP/1w9BAGAfLoIAKw/NIm8OZQgPhgEfDwQBbg6GAQ9CEP//ag+MYTAPQhBqLycqQhAtHidCEA+MYYoPQhADBsUgLi4uxSAPjGEcHy5Rgi0fLt8H/1oPZhksB4ALD+wa5g8EATAfLrNb/5kNRwoPRQGxH3oIgGIP+Q3/gg8QQ3APQhD/vg/6m60OZAgPhgHQD85x/xwOSUEPznERD4xhmQ3GMA+MYZ8PxjACD4xhWg8IQQMPjGGaD8YwAQV0IB8ujGE5D90mYg9/Tv9ZDz5Obg6aSw9FAaEPLAv/nR8uCEHFDwiAmw9BAP////9dD6Zntg9CEDwPjGH/Gg9CEAQPjGGZDghBD4xhng9CEAIPjGFaDkIQAN6yDoxhDwEQGh8ujGEXHyyMYS8MQhAWLkdBD85xPA+YJVkPdLr/og/ADzEPBAFuDygK/5oPMVsvHy6CAC0PRQGCUCwsLCwKAAAAAJeLtZI=" diff --git a/cli/shell/program.go b/cli/shell/program.go new file mode 100644 index 0000000000..41937ae61c --- /dev/null +++ b/cli/shell/program.go @@ -0,0 +1,197 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import ( + "fmt" + "io" + "os" + + tea "github.com/charmbracelet/bubbletea" + "github.com/mattn/go-isatty" + "go.mondoo.com/cnquery/v12" + "go.mondoo.com/cnquery/v12/cli/theme" + "go.mondoo.com/cnquery/v12/llx" + "go.mondoo.com/cnquery/v12/mql" + "go.mondoo.com/cnquery/v12/mqlc" + "go.mondoo.com/cnquery/v12/providers" + "go.mondoo.com/cnquery/v12/providers-sdk/v1/upstream" + "go.mondoo.com/cnquery/v12/utils/stringx" +) + +// Option configures a ShellProgram +type Option func(*ShellProgram) + +// WithTheme sets the shell theme +func WithTheme(theme *ShellTheme) Option { + return func(s *ShellProgram) { + s.theme = theme + } +} + +// WithFeatures sets the cnquery features +func WithFeatures(features cnquery.Features) Option { + return func(s *ShellProgram) { + s.features = features + } +} + +// WithUpstreamConfig sets the upstream configuration +func WithUpstreamConfig(c *upstream.UpstreamConfig) Option { + return func(s *ShellProgram) { + s.upstreamConfig = c + } +} + +// WithOnClose sets a callback to run when the shell closes +func WithOnClose(handler func()) Option { + return func(s *ShellProgram) { + s.onCloseHandler = handler + } +} + +// WithOutput sets the output writer for non-interactive query execution +func WithOutput(w io.Writer) Option { + return func(s *ShellProgram) { + s.out = w + } +} + +// WithMaxLines sets the maximum number of lines to display in output +func WithMaxLines(n int) Option { + return func(s *ShellProgram) { + s.maxLines = n + } +} + +// ShellProgram is the main entry point for the shell +// It supports both interactive mode (Run) and non-interactive query execution (RunOnce) +type ShellProgram struct { + runtime llx.Runtime + theme *ShellTheme + features cnquery.Features + upstreamConfig *upstream.UpstreamConfig + onCloseHandler func() + out io.Writer + maxLines int + printTheme *theme.Theme +} + +// NewShell creates a new shell program +// It can be used for interactive mode (Run) or non-interactive query execution (RunOnce) +func NewShell(runtime llx.Runtime, opts ...Option) *ShellProgram { + s := &ShellProgram{ + runtime: runtime, + theme: DefaultShellTheme, + features: cnquery.DefaultFeatures, + out: os.Stdout, + maxLines: 1024, + printTheme: theme.DefaultTheme, + } + + for _, opt := range opts { + opt(s) + } + + // Set upstream config on runtime if provided + if s.upstreamConfig != nil { + if x, ok := s.runtime.(*providers.Runtime); ok { + x.UpstreamConfig = s.upstreamConfig + } + } + + // Initialize the policy printer with the schema + schema := runtime.Schema() + s.printTheme.PolicyPrinter.SetSchema(schema) + + return s +} + +// Run starts the interactive shell +func (s *ShellProgram) Run() error { + return s.RunWithCommand("") +} + +// RunWithCommand starts the interactive shell and optionally executes an initial command +func (s *ShellProgram) RunWithCommand(initialCmd string) error { + // Check if we're running in a terminal + if !isatty.IsTerminal(os.Stdin.Fd()) { + return ErrNotTTY + } + + // Get connected provider IDs to filter autocomplete suggestions + var connectedProviderIDs []string + if r, ok := s.runtime.(*providers.Runtime); ok { + connectedProviderIDs = r.ConnectedProviderIDs() + } + + // Create the model + model := newShellModel(s.runtime, s.theme, s.features, initialCmd, connectedProviderIDs) + + // Create and run the Bubble Tea program + // Note: We don't use WithAltScreen() so output stays in terminal scrollback + // Note: We don't use WithMouseCellMotion() so terminal handles text selection natively + p := tea.NewProgram(model) + + finalModel, err := p.Run() + if err != nil { + return err + } + + // Handle cleanup + if m, ok := finalModel.(*shellModel); ok { + m.saveHistory() + } + + // Close runtime + s.runtime.Close() + + // Run close handler if set + if s.onCloseHandler != nil { + s.onCloseHandler() + } + + return nil +} + +// Close cleans up the shell resources +func (s *ShellProgram) Close() { + s.runtime.Close() + if s.onCloseHandler != nil { + s.onCloseHandler() + } +} + +// RunOnce executes a query and returns the results (non-interactive) +func (s *ShellProgram) RunOnce(cmd string) (*llx.CodeBundle, map[string]*llx.RawResult, error) { + code, err := mqlc.Compile(cmd, nil, mqlc.NewConfig(s.runtime.Schema(), s.features)) + if err != nil { + fmt.Fprintln(s.out, s.printTheme.Error("failed to compile: "+err.Error())) + + if code != nil && code.Suggestions != nil { + fmt.Fprintln(s.out, formatSuggestions(code.Suggestions, s.printTheme)) + } + return nil, nil, err + } + + res, err := s.RunOnceBundle(code) + return code, res, err +} + +// RunOnceBundle executes a pre-compiled code bundle and returns results (non-interactive) +func (s *ShellProgram) RunOnceBundle(code *llx.CodeBundle) (map[string]*llx.RawResult, error) { + return mql.ExecuteCode(s.runtime, code, nil, s.features) +} + +// PrintResults prints the results of a query execution to the output writer +func (s *ShellProgram) PrintResults(code *llx.CodeBundle, results map[string]*llx.RawResult) { + printedResult := s.printTheme.PolicyPrinter.Results(code, results) + + if s.maxLines > 0 { + printedResult = stringx.MaxLines(s.maxLines, printedResult) + } + + fmt.Fprint(s.out, "\r") + fmt.Fprintln(s.out, printedResult) +} diff --git a/cli/shell/shell.go b/cli/shell/shell.go index cc95358718..961a323cd5 100644 --- a/cli/shell/shell.go +++ b/cli/shell/shell.go @@ -4,438 +4,12 @@ package shell import ( - "fmt" - "io" - "os" - "os/signal" - "path" - "regexp" - "sort" "strings" - prompt "github.com/c-bata/go-prompt" - "github.com/mitchellh/go-homedir" - "github.com/rs/zerolog/log" - "go.mondoo.com/cnquery/v12" "go.mondoo.com/cnquery/v12/cli/theme" "go.mondoo.com/cnquery/v12/llx" - "go.mondoo.com/cnquery/v12/mql" - "go.mondoo.com/cnquery/v12/mqlc" - "go.mondoo.com/cnquery/v12/mqlc/parser" - "go.mondoo.com/cnquery/v12/providers" - "go.mondoo.com/cnquery/v12/providers-sdk/v1/resources" - "go.mondoo.com/cnquery/v12/providers-sdk/v1/upstream" - "go.mondoo.com/cnquery/v12/types" - "go.mondoo.com/cnquery/v12/utils/sortx" - "go.mondoo.com/cnquery/v12/utils/stringx" ) -type ShellOption func(c *Shell) - -func WithOnCloseListener(onCloseHandler func()) ShellOption { - return func(t *Shell) { - t.onCloseHandler = onCloseHandler - } -} - -func WithUpstreamConfig(c *upstream.UpstreamConfig) ShellOption { - return func(t *Shell) { - if x, ok := t.Runtime.(*providers.Runtime); ok { - x.UpstreamConfig = c - } - } -} - -func WithFeatures(features cnquery.Features) ShellOption { - return func(t *Shell) { - t.features = features - } -} - -func WithOutput(writer io.Writer) ShellOption { - return func(t *Shell) { - t.out = writer - } -} - -func WithTheme(theme *theme.Theme) ShellOption { - return func(t *Shell) { - t.Theme = theme - } -} - -// Shell is the interactive explorer -type Shell struct { - Runtime llx.Runtime - Theme *theme.Theme - History []string - HistoryPath string - MaxLines int - - completer *Completer - out io.Writer - features cnquery.Features - onCloseHandler func() - query string - isMultiline bool - multilineIndent int -} - -// New creates a new Shell -func New(runtime llx.Runtime, opts ...ShellOption) (*Shell, error) { - res := &Shell{ - out: os.Stdout, - features: cnquery.DefaultFeatures, - MaxLines: 1024, - Runtime: runtime, - } - - for _, opt := range opts { - opt(res) - } - - if res.Theme == nil { - res.Theme = theme.DefaultTheme - } - - schema := runtime.Schema() - res.Theme.PolicyPrinter.SetSchema(schema) - - res.completer = NewCompleter(runtime.Schema(), res.features, func() string { - return res.query - }) - - return res, nil -} - -func (s *Shell) printWelcome() { - if s.Theme.Welcome == "" { - return - } - - fmt.Fprintln(s.out, s.Theme.Welcome) -} - -// RunInteractive starts a REPL loop -func (s *Shell) RunInteractive(cmd string) { - s.backupTerminalSettings() - s.printWelcome() - - s.History = []string{} - homeDir, _ := homedir.Dir() - s.HistoryPath = path.Join(homeDir, ".mondoo_history") - if rawHistory, err := os.ReadFile(s.HistoryPath); err == nil { - s.History = strings.Split(string(rawHistory), "\n") - } - - if cmd != "" { - s.ExecCmd(cmd) - s.History = append(s.History, cmd) - } - - completer := s.completer.CompletePrompt - - p := prompt.New( - s.ExecCmd, - completer, - prompt.OptionPrefix(s.Theme.Prefix), - prompt.OptionPrefixTextColor(s.Theme.PromptColors.PrefixTextColor), - prompt.OptionLivePrefix(s.changeLivePrefix), - prompt.OptionPreviewSuggestionTextColor(s.Theme.PromptColors.PreviewSuggestionTextColor), - prompt.OptionPreviewSuggestionBGColor(s.Theme.PromptColors.PreviewSuggestionBGColor), - prompt.OptionSelectedSuggestionTextColor(s.Theme.PromptColors.SelectedSuggestionTextColor), - prompt.OptionSelectedSuggestionBGColor(s.Theme.PromptColors.SelectedSuggestionBGColor), - prompt.OptionSuggestionTextColor(s.Theme.PromptColors.SuggestionTextColor), - prompt.OptionSuggestionBGColor(s.Theme.PromptColors.SuggestionBGColor), - prompt.OptionDescriptionTextColor(s.Theme.PromptColors.DescriptionTextColor), - prompt.OptionDescriptionBGColor(s.Theme.PromptColors.DescriptionBGColor), - prompt.OptionSelectedDescriptionTextColor(s.Theme.PromptColors.SelectedDescriptionTextColor), - prompt.OptionSelectedDescriptionBGColor(s.Theme.PromptColors.SelectedDescriptionBGColor), - prompt.OptionScrollbarBGColor(s.Theme.PromptColors.ScrollbarBGColor), - prompt.OptionScrollbarThumbColor(s.Theme.PromptColors.ScrollbarThumbColor), - prompt.OptionAddKeyBind( - prompt.KeyBind{ - Key: prompt.ControlD, - Fn: func(buf *prompt.Buffer) { - s.handleExit() - }, - }, - prompt.KeyBind{ - Key: prompt.ControlZ, - Fn: func(buf *prompt.Buffer) { - s.suspend() - }, - }, - ), - prompt.OptionHistory(s.History), - prompt.OptionCompletionWordSeparator(completerSeparator), - ) - - p.Run() - - s.handleExit() -} - -var helpResource = regexp.MustCompile(`help\s(.*)`) - -func (s *Shell) ExecCmd(cmd string) { - switch { - case s.isMultiline: - s.execQuery(cmd) - case cmd == "": - return - case cmd == "quit": - fallthrough - case cmd == "exit": - s.handleExit() - return - case cmd == "clear": - // clear screen - s.out.Write([]byte{0x1b, '[', '2', 'J'}) - // move cursor to home - s.out.Write([]byte{0x1b, '[', 'H'}) - return - case cmd == "help": - s.listAvailableResources() - return - case cmd == "nyanya": - size := prompt.NewStandardInputParser().GetWinSize() - nyago(int(size.Col), int(size.Row)) - return - case helpResource.MatchString(cmd): - s.listFilteredResources(cmd) - return - default: - s.execQuery(cmd) - } -} - -func (s *Shell) execQuery(cmd string) { - s.query += " " + cmd - - // Note: we could optimize the call structure here, since compile - // will end up being called twice. However, since we are talking about - // the shell and we only deal with one query at a time, with the - // compiler being rather fast, the additional time is negligible - // and may not be worth coding around. - code, err := mqlc.Compile(s.query, nil, mqlc.NewConfig(s.Runtime.Schema(), s.features)) - if err != nil { - if e, ok := err.(*parser.ErrIncomplete); ok { - s.isMultiline = true - s.multilineIndent = e.Indent - return - } - } - - // at this point we know this is not a multi-line call anymore - - cleanCommand := s.query - if code != nil { - cleanCommand = code.Source - } - - if len(s.History) == 0 || s.History[len(s.History)-1] != cleanCommand { - s.History = append(s.History, cleanCommand) - } - - code, res, err := s.RunOnce(s.query) - // we can safely ignore err != nil, since RunOnce handles most of the printing we need - if err == nil { - s.PrintResults(code, res) - } - - s.isMultiline = false - s.query = "" -} - -func (s *Shell) changeLivePrefix() (string, bool) { - if s.isMultiline { - indent := strings.Repeat(" ", s.multilineIndent*2) - return " .. > " + indent, true - } - return "", false -} - -// handleExit is called when the user wants to exit the shell, it restores the terminal -// when the interactive prompt has been used and writes the history to disk. Once that -// is completed it calls Close() to call the optional close handler for the provider -func (s *Shell) handleExit() { - rawHistory := strings.Join(s.History, "\n") - err := os.WriteFile(s.HistoryPath, []byte(rawHistory), 0o640) - if err != nil { - log.Error().Err(err).Msg("failed to save history") - } - - s.restoreTerminalSettings() - - // run onClose handler if set - s.Close() - - os.Exit(0) -} - -// Close is called when the shell is closed and calls the onCloseHandler -func (s *Shell) Close() { - s.Runtime.Close() - // run onClose handler if set - if s.onCloseHandler != nil { - s.onCloseHandler() - } -} - -// RunOnce executes the query and returns results -func (s *Shell) RunOnce(cmd string) (*llx.CodeBundle, map[string]*llx.RawResult, error) { - code, err := mqlc.Compile(cmd, nil, mqlc.NewConfig(s.Runtime.Schema(), s.features)) - if err != nil { - fmt.Fprintln(s.out, s.Theme.Error("failed to compile: "+err.Error())) - - if code != nil && code.Suggestions != nil { - fmt.Fprintln(s.out, formatSuggestions(code.Suggestions, s.Theme)) - } - return nil, nil, err - } - - res, err := s.RunOnceBundle(code) - return code, res, err -} - -// RunOnceBundle executes the given code bundle and returns results -func (s *Shell) RunOnceBundle(code *llx.CodeBundle) (map[string]*llx.RawResult, error) { - return mql.ExecuteCode(s.Runtime, code, nil, s.features) -} - -func (s *Shell) PrintResults(code *llx.CodeBundle, results map[string]*llx.RawResult) { - printedResult := s.Theme.PolicyPrinter.Results(code, results) - - if s.MaxLines > 0 { - printedResult = stringx.MaxLines(s.MaxLines, printedResult) - } - - fmt.Fprint(s.out, "\r") - fmt.Fprintln(s.out, printedResult) -} - -func indent(indent int) string { - indentTxt := "" - for i := 0; i < indent; i++ { - indentTxt += " " - } - return indentTxt -} - -// listAvailableResources lists resource names and their title -func (s *Shell) listAvailableResources() { - resources := s.Runtime.Schema().AllResources() - keys := sortx.Keys(resources) - s.renderResources(resources, keys) -} - -// listFilteredResources displays the schema of one or many resources that start with the provided prefix -func (s *Shell) listFilteredResources(cmd string) { - m := helpResource.FindStringSubmatch(cmd) - if len(m) == 0 { - return - } - - search := m[1] - resources := s.Runtime.Schema().AllResources() - - // if we find the requested resource, just return it - if _, ok := resources[search]; ok { - s.renderResources(resources, []string{search}) - return - } - - // otherwise we will look for anything that matches - keys := []string{} - for k := range resources { - if strings.HasPrefix(k, search) { - keys = append(keys, k) - } - } - sort.Strings(keys) - s.renderResources(resources, keys) -} - -// renderResources renders a set of resources from a given schema -func (s *Shell) renderResources(resources map[string]*resources.ResourceInfo, keys []string) { - // list resources and field - type rowEntry struct { - key string - keylength int - value string - } - - rows := []rowEntry{} - maxk := 0 - const separator = ":" - - for i := range keys { - k := keys[i] - resource := resources[k] - - keyLength := len(resource.Name) + len(separator) - rows = append(rows, rowEntry{ - s.Theme.PolicyPrinter.Secondary(resource.Name) + separator, - keyLength, - resource.Title, - }) - if maxk < keyLength { - maxk = keyLength - } - - fields := sortx.Keys(resource.Fields) - for i := range fields { - field := resource.Fields[fields[i]] - if field.IsPrivate { - continue - } - - fieldName := " " + field.Name - fieldType := types.Type(field.Type).Label() - displayType := "" - fieldComment := field.Title - if fieldComment == "" && types.Type(field.Type).IsResource() { - r, ok := resources[fieldType] - if ok { - fieldComment = r.Title - } - } - if len(fieldType) > 0 { - fieldType = " " + fieldType - displayType = s.Theme.PolicyPrinter.Disabled(fieldType) - } - - keyLength = len(fieldName) + len(fieldType) + len(separator) - rows = append(rows, rowEntry{ - s.Theme.PolicyPrinter.Secondary(fieldName) + displayType + separator, - keyLength, - fieldComment, - }) - if maxk < keyLength { - maxk = keyLength - } - } - } - - for i := range rows { - entry := rows[i] - fmt.Fprintln(s.out, entry.key+indent(maxk-entry.keylength+1)+entry.value) - } -} - -// capture the interrupt signal (SIGINT) once and notify a given channel -func captureSIGINTonce(sig chan<- struct{}) { - c := make(chan os.Signal, 1) - signal.Notify(c, os.Interrupt) - - go func() { - <-c - signal.Stop(c) - sig <- struct{}{} - }() -} - func formatSuggestions(suggestions []*llx.Documentation, theme *theme.Theme) string { var res strings.Builder res.WriteString(theme.Secondary("\nsuggestions: \n")) diff --git a/cli/shell/shell_test.go b/cli/shell/shell_test.go index 19779b6420..70cb396c50 100644 --- a/cli/shell/shell_test.go +++ b/cli/shell/shell_test.go @@ -11,45 +11,29 @@ import ( "go.mondoo.com/cnquery/v12/providers-sdk/v1/testutils" ) -func localShell() *shell.Shell { +func localShell() *shell.ShellProgram { runtime := testutils.LinuxMock() - res, err := shell.New(runtime) - if err != nil { - panic(err.Error()) - } - - return res + return shell.NewShell(runtime) } func TestShell_RunOnce(t *testing.T) { - shell := localShell() + sh := localShell() assert.NotPanics(t, func() { - shell.RunOnce("mondoo.build") + _, _, _ = sh.RunOnce("mondoo.build") }, "should not panic on partial queries") assert.NotPanics(t, func() { - shell.RunOnce("mondoo { build version }") + _, _, _ = sh.RunOnce("mondoo { build version }") }, "should not panic on partial queries") assert.NotPanics(t, func() { - shell.RunOnce("mondoo { _.version }") + _, _, _ = sh.RunOnce("mondoo { _.version }") }, "should not panic on partial queries") } -func TestShell_Help(t *testing.T) { - shell := localShell() - assert.NotPanics(t, func() { - shell.ExecCmd("help") - }, "should not panic on help command") - - assert.NotPanics(t, func() { - shell.ExecCmd("help platform") - }, "should not panic on help subcommand") -} - func TestShell_Centos8(t *testing.T) { - shell := localShell() + sh := localShell() assert.NotPanics(t, func() { - shell.RunOnce("platform { title name release arch }") + _, _, _ = sh.RunOnce("platform { title name release arch }") }, "should not panic on partial queries") } diff --git a/cli/shell/terminal_posix.go b/cli/shell/terminal_posix.go deleted file mode 100644 index d4fe1ae6cd..0000000000 --- a/cli/shell/terminal_posix.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -//go:build !windows - -package shell - -import ( - "os/signal" - "syscall" - - "github.com/pkg/term/termios" - "go.mondoo.com/cnquery/v12/utils/piped" - "golang.org/x/sys/unix" -) - -var terminalIos *unix.Termios - -func (s *Shell) backupTerminalSettings() { - // we only backup if we have no input pipe - if piped.IsPipe() { - return - } - - var err error - terminalIos, err = termios.Tcgetattr(uintptr(syscall.Stdin)) - if err != nil { - panic(err) - } -} - -func (s *Shell) restoreTerminalSettings() { - signal.Reset(syscall.SIGINT, - syscall.SIGTERM, - syscall.SIGQUIT, - syscall.SIGWINCH) - syscall.SetNonblock(syscall.Stdin, false) - termios.Tcsetattr(uintptr(syscall.Stdin), termios.TCSANOW, terminalIos) -} - -func (s *Shell) suspend() { - syscall.Kill(syscall.Getppid(), syscall.SIGTSTP) -} diff --git a/cli/shell/terminal_windows.go b/cli/shell/terminal_windows.go deleted file mode 100644 index 69bca133a8..0000000000 --- a/cli/shell/terminal_windows.go +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) Mondoo, Inc. -// SPDX-License-Identifier: BUSL-1.1 - -//go:build windows - -package shell - -func (s *Shell) backupTerminalSettings() {} - -func (s *Shell) restoreTerminalSettings() {} - -func (s *Shell) suspend() {} diff --git a/cli/shell/theme_bubbletea.go b/cli/shell/theme_bubbletea.go new file mode 100644 index 0000000000..6e1416ab71 --- /dev/null +++ b/cli/shell/theme_bubbletea.go @@ -0,0 +1,143 @@ +// Copyright (c) Mondoo, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package shell + +import ( + "github.com/charmbracelet/lipgloss" + "go.mondoo.com/cnquery/v12/cli/printer" +) + +// ShellTheme defines the visual appearance of the shell +type ShellTheme struct { + // Input styling + Prompt lipgloss.Style + MultilinePrompt lipgloss.Style + InputText lipgloss.Style + + // Completion popup + PopupBorder lipgloss.Style + SuggestionNormal lipgloss.Style + SuggestionSelected lipgloss.Style + DescriptionNormal lipgloss.Style + DescriptionSelected lipgloss.Style + ScrollIndicator lipgloss.Style + + // Output + OutputArea lipgloss.Style + Error lipgloss.Style + Success lipgloss.Style + Secondary lipgloss.Style + Disabled lipgloss.Style + + // Status + Spinner lipgloss.Style + HelpBar lipgloss.Style + HelpKey lipgloss.Style + HelpText lipgloss.Style + + // Text content + Welcome string + Prefix string + + // Printer for results + PolicyPrinter printer.Printer +} + +// Color constants matching the original theme +var ( + colorPurple = lipgloss.Color("133") // Purple for prefix and selected items + colorFuchsia = lipgloss.Color("201") // Fuchsia for accents + colorWhite = lipgloss.Color("15") // White for selected text + colorRed = lipgloss.Color("196") // Red for errors + colorGreen = lipgloss.Color("82") // Green for success + colorDisabled = lipgloss.Color("245") // Gray for disabled text +) + +// DefaultShellTheme is the default theme for the shell +var DefaultShellTheme = &ShellTheme{ + // Input + Prompt: lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true), + MultilinePrompt: lipgloss.NewStyle(). + Foreground(colorPurple), + InputText: lipgloss.NewStyle(), + + // Completion popup + PopupBorder: lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorPurple), + SuggestionNormal: lipgloss.NewStyle(). + Foreground(lipgloss.Color("252")), + SuggestionSelected: lipgloss.NewStyle(). + Foreground(colorWhite). + Background(colorPurple). + Bold(true), + DescriptionNormal: lipgloss.NewStyle(). + Foreground(colorDisabled), + DescriptionSelected: lipgloss.NewStyle(). + Foreground(colorWhite). + Background(colorPurple), + ScrollIndicator: lipgloss.NewStyle(). + Foreground(colorDisabled), + + // Output + OutputArea: lipgloss.NewStyle(). + MarginTop(1), + Error: lipgloss.NewStyle(). + Foreground(colorRed), + Success: lipgloss.NewStyle(). + Foreground(colorGreen), + Secondary: lipgloss.NewStyle(). + Foreground(colorPurple), + Disabled: lipgloss.NewStyle(). + Foreground(colorDisabled), + + // Status + Spinner: lipgloss.NewStyle(). + Foreground(colorFuchsia), + HelpBar: lipgloss.NewStyle(). + Foreground(colorDisabled), + HelpKey: lipgloss.NewStyle(). + Foreground(colorPurple). + Bold(true), + HelpText: lipgloss.NewStyle(). + Foreground(colorDisabled), + + // Text + Welcome: logo + " interactive shell\n", + Prefix: "> ", + + // Printer + PolicyPrinter: printer.DefaultPrinter, +} + +// logo for the shell +const logo = ` + ___ _ __ __ _ _ _ ___ _ __ _ _ + / __| '_ \ / _` + "`" + ` | | | |/ _ \ '__| | | | +| (__| | | | (_| | |_| | __/ | | |_| | + \___|_| |_|\__, |\__,_|\___|_| \__, | + mondoo™ |_| |___/ +` + +// Error formats a string as an error message +func (t *ShellTheme) ErrorText(s string) string { + return t.Error.Render(s) +} + +// SuccessText formats a string as a success message +func (t *ShellTheme) SuccessText(s string) string { + return t.Success.Render(s) +} + +// SecondaryText formats a string as secondary text +func (t *ShellTheme) SecondaryText(s string) string { + return t.Secondary.Render(s) +} + +// DisabledText formats a string as disabled/dimmed text +func (t *ShellTheme) DisabledText(s string) string { + return t.Disabled.Render(s) +} diff --git a/cli/theme/os_colors.go b/cli/theme/os_colors.go index 069992a108..fd99f7ab74 100644 --- a/cli/theme/os_colors.go +++ b/cli/theme/os_colors.go @@ -8,7 +8,6 @@ package theme import ( "strings" - prompt "github.com/c-bata/go-prompt" "github.com/muesli/termenv" "go.mondoo.com/cnquery/v12/cli/printer" "go.mondoo.com/cnquery/v12/cli/theme/colors" @@ -17,21 +16,6 @@ import ( // OperatingSystemTheme for unix shell var OperatingSystemTheme = &Theme{ Colors: colors.DefaultColorTheme, - PromptColors: PromptColors{ - PrefixTextColor: prompt.Purple, - PreviewSuggestionTextColor: prompt.Blue, - PreviewSuggestionBGColor: prompt.DefaultColor, - SuggestionTextColor: prompt.DefaultColor, - SuggestionBGColor: prompt.DarkGray, - SelectedSuggestionTextColor: prompt.White, - SelectedSuggestionBGColor: prompt.Purple, - DescriptionTextColor: prompt.DefaultColor, - DescriptionBGColor: prompt.Purple, - SelectedDescriptionTextColor: prompt.DefaultColor, - SelectedDescriptionBGColor: prompt.Fuchsia, - ScrollbarBGColor: prompt.Fuchsia, - ScrollbarThumbColor: prompt.DefaultColor, - }, List: func(items ...string) string { var w strings.Builder for i := range items { diff --git a/cli/theme/os_colors_windows.go b/cli/theme/os_colors_windows.go index 243dff015d..1cd8da5dbb 100644 --- a/cli/theme/os_colors_windows.go +++ b/cli/theme/os_colors_windows.go @@ -6,7 +6,6 @@ package theme import ( "strings" - prompt "github.com/c-bata/go-prompt" "github.com/muesli/termenv" "go.mondoo.com/cnquery/v12/cli/printer" "go.mondoo.com/cnquery/v12/cli/theme/colors" @@ -15,22 +14,6 @@ import ( // OperatingSystemTheme for windows shell var OperatingSystemTheme = &Theme{ Colors: colors.DefaultColorTheme, - // NOTE: windows cmd does not render purple well - PromptColors: PromptColors{ - PrefixTextColor: prompt.Fuchsia, - PreviewSuggestionTextColor: prompt.Fuchsia, - PreviewSuggestionBGColor: prompt.DefaultColor, - SuggestionTextColor: prompt.Black, - SuggestionBGColor: prompt.White, - SelectedSuggestionTextColor: prompt.White, - SelectedSuggestionBGColor: prompt.Fuchsia, - DescriptionTextColor: prompt.DefaultColor, - DescriptionBGColor: prompt.Fuchsia, - SelectedDescriptionTextColor: prompt.Fuchsia, - SelectedDescriptionBGColor: prompt.White, - ScrollbarBGColor: prompt.Fuchsia, - ScrollbarThumbColor: prompt.White, - }, List: func(items ...string) string { var w strings.Builder for i := range items { diff --git a/cli/theme/theme.go b/cli/theme/theme.go index 30caffd419..3b152de61b 100644 --- a/cli/theme/theme.go +++ b/cli/theme/theme.go @@ -6,32 +6,14 @@ package theme import ( "fmt" - "github.com/c-bata/go-prompt" "github.com/muesli/termenv" "go.mondoo.com/cnquery/v12/cli/printer" "go.mondoo.com/cnquery/v12/cli/theme/colors" ) -type PromptColors struct { - PrefixTextColor prompt.Color - PreviewSuggestionTextColor prompt.Color - PreviewSuggestionBGColor prompt.Color - SuggestionTextColor prompt.Color - SuggestionBGColor prompt.Color - SelectedSuggestionTextColor prompt.Color - SelectedSuggestionBGColor prompt.Color - DescriptionTextColor prompt.Color - DescriptionBGColor prompt.Color - SelectedDescriptionTextColor prompt.Color - SelectedDescriptionBGColor prompt.Color - ScrollbarBGColor prompt.Color - ScrollbarThumbColor prompt.Color -} - // Theme to configure how the shell will look and feel type Theme struct { - Colors colors.Theme - PromptColors PromptColors + Colors colors.Theme List func(...string) string Landing string diff --git a/go.mod b/go.mod index 86c7f08e22..0b978b38b4 100644 --- a/go.mod +++ b/go.mod @@ -26,8 +26,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7 github.com/aws/smithy-go v1.24.0 github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.11.0 - // pin v0.2.6 - github.com/c-bata/go-prompt v0.2.6 github.com/charmbracelet/bubbles v0.21.0 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 @@ -71,8 +69,6 @@ require ( github.com/package-url/packageurl-go v0.1.3 github.com/pierrec/lz4/v4 v4.1.23 github.com/pkg/sftp v1.13.10 - // pin v1.2.0-beta.2 - github.com/pkg/term v1.2.0-beta.2 github.com/protobom/protobom v0.5.4 github.com/rs/zerolog v1.34.0 github.com/segmentio/fasthash v1.0.3 @@ -226,7 +222,6 @@ require ( github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.19 // indirect - github.com/mattn/go-tty v0.0.7 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect diff --git a/go.sum b/go.sum index 9bf5fa8d32..ffda69140a 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/CycloneDX/cyclonedx-go v0.9.3/go.mod h1:vcK6pKgO1WanCdd61qx4bFnSsDJQ6 github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/GoogleCloudPlatform/berglas/v2 v2.0.2 h1:yOsxOcmOkqxcG0dl4wkV5BmwE0aiNkk9AmsDnxykUCI= github.com/GoogleCloudPlatform/berglas/v2 v2.0.2/go.mod h1:OXu1gABDdDAbwX3YpE2rSjaEOdd8xhQquNKbUH0aNcg= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= @@ -200,8 +202,6 @@ github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oM github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= -github.com/c-bata/go-prompt v0.2.6 h1:POP+nrHE+DfLYx370bedwNhsqmpCUynWPxuHi0C5vZI= -github.com/c-bata/go-prompt v0.2.6/go.mod h1:/LMAke8wD2FsNu9EXNdHxNLbd9MedkPnCdfpU9wwHfY= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -670,7 +670,6 @@ github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -688,14 +687,9 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-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.6/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -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.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= -github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= -github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= @@ -803,8 +797,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= -github.com/pkg/term v1.2.0-beta.2 h1:L3y/h2jkuBVFdWiJvNfYfKmzcCnILw7mJWm2JQuMppw= -github.com/pkg/term v1.2.0-beta.2/go.mod h1:E25nymQcrSllhX42Ok8MRm1+hyBdHY0dCeiKZ9jpNGw= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -1178,7 +1170,6 @@ golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1198,8 +1189,6 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200828081204-131dc92a58d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/providers/runtime.go b/providers/runtime.go index 6164dfc94f..384ca9dfd7 100644 --- a/providers/runtime.go +++ b/providers/runtime.go @@ -133,6 +133,18 @@ func (r *Runtime) AddConnectedProvider(c *ConnectedProvider) { r.mu.Unlock() } +// ConnectedProviderIDs returns the IDs of all connected providers +func (r *Runtime) ConnectedProviderIDs() []string { + r.mu.Lock() + defer r.mu.Unlock() + + ids := make([]string, 0, len(r.providers)) + for id := range r.providers { + ids = append(ids, id) + } + return ids +} + func (r *Runtime) setProviderConnection(c *plugin.ConnectRes, err error) { r.mu.Lock() r.Provider.Connection = c