diff --git a/cmd/cli/api_prompt.go b/cmd/cli/api_prompt.go new file mode 100644 index 00000000..f08df9d1 --- /dev/null +++ b/cmd/cli/api_prompt.go @@ -0,0 +1,94 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/jmpsec/osctrl/pkg/apiclient" + "golang.org/x/term" +) + +// missingAPIConfiguration reports whether the loaded API configuration is +// unusable: both URL and token must be present for any API command to work. +func missingAPIConfiguration(conf apiclient.JSONConfigurationAPI) bool { + return conf.URL == "" || conf.Token == "" +} + +// isTerminalStdin reports whether stdin is an interactive terminal. Prompts +// are only offered when this is true; non-interactive callers fail with the +// regular configuration errors instead of blocking on a prompt. +func isTerminalStdin() bool { + return term.IsTerminal(int(os.Stdin.Fd())) +} + +// promptMissingAPIConfiguration asks for the API URL and token values that +// are still missing. Values already present (flags, environment, config +// file) are accepted as-is and not re-asked. The token is read without +// echoing. Returns an error in non-interactive contexts or when the user +// provides an empty value. +func promptMissingAPIConfiguration(conf *apiclient.JSONConfigurationAPI) error { + if !missingAPIConfiguration(*conf) { + return nil + } + if !isTerminalStdin() { + if conf.URL == "" && conf.Token == "" { + return fmt.Errorf("no API configuration found: use --api-file, --api-url/--api-token, API_URL/API_TOKEN, or run interactively to be prompted") + } + if conf.URL == "" { + return fmt.Errorf("API URL is required: use --api-url, API_URL, or run interactively to be prompted") + } + return fmt.Errorf("API token is required: use --api-token, API_TOKEN, or run interactively to be prompted") + } + fmt.Println("No osctrl API configuration found. Please provide the connection details.") + if conf.URL == "" { + value, err := promptLine("API URL") + if err != nil { + return err + } + conf.URL = value + } + if conf.Token == "" { + value, err := promptSecret("API token") + if err != nil { + return err + } + conf.Token = value + } + return nil +} + +// promptLine reads a non-hidden value from the terminal. +func promptLine(label string) (string, error) { + for { + fmt.Printf(" -> %s: ", label) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", fmt.Errorf("error reading %s: %w", strings.ToLower(label), err) + } + value := strings.TrimSpace(line) + if value != "" { + return value, nil + } + fmt.Printf("❌ %s is required\n", label) + } +} + +// promptSecret reads a hidden value from the terminal, echoing a newline so +// the following output starts on a fresh line. +func promptSecret(label string) (string, error) { + for { + fmt.Printf(" -> %s: ", label) + value, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Println() + if err != nil { + return "", fmt.Errorf("error reading %s: %w", strings.ToLower(label), err) + } + secret := strings.TrimSpace(string(value)) + if secret != "" { + return secret, nil + } + fmt.Printf("❌ %s is required\n", label) + } +} diff --git a/cmd/cli/api_prompt_test.go b/cmd/cli/api_prompt_test.go new file mode 100644 index 00000000..6b07cb50 --- /dev/null +++ b/cmd/cli/api_prompt_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "testing" + + "github.com/jmpsec/osctrl/pkg/apiclient" + "github.com/stretchr/testify/require" +) + +func TestMissingAPIConfiguration(t *testing.T) { + require.False(t, missingAPIConfiguration(apiclient.JSONConfigurationAPI{URL: "https://osctrl", Token: "t"})) + require.True(t, missingAPIConfiguration(apiclient.JSONConfigurationAPI{Token: "t"})) + require.True(t, missingAPIConfiguration(apiclient.JSONConfigurationAPI{URL: "https://osctrl"})) + require.True(t, missingAPIConfiguration(apiclient.JSONConfigurationAPI{})) +} + +// Non-interactive contexts (pipes, scripts, CI) must fail with guidance +// instead of blocking on a prompt — while keeping partial values so the +// error names exactly what is missing. +func TestPromptMissingAPIConfigurationNonInteractive(t *testing.T) { + both := apiclient.JSONConfigurationAPI{} + err := promptMissingAPIConfiguration(&both) + require.Error(t, err) + require.Contains(t, err.Error(), "no API configuration found") + + urlOnly := apiclient.JSONConfigurationAPI{URL: "https://osctrl"} + err = promptMissingAPIConfiguration(&urlOnly) + require.Error(t, err) + require.Contains(t, err.Error(), "token is required") + + tokenOnly := apiclient.JSONConfigurationAPI{Token: "t"} + err = promptMissingAPIConfiguration(&tokenOnly) + require.Error(t, err) + require.Contains(t, err.Error(), "URL is required") + + complete := apiclient.JSONConfigurationAPI{URL: "https://osctrl", Token: "t"} + require.NoError(t, promptMissingAPIConfiguration(&complete)) + require.Equal(t, "https://osctrl", complete.URL) + require.Equal(t, "t", complete.Token) +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 703d531c..ad6cc310 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -2056,9 +2056,19 @@ func checkAPI(ctx context.Context, cmd *cli.Command) error { } if apiFlag { if apiConfigFile != "" { - apiConfig, err = apiclient.LoadConfiguration(apiConfigFile) - if err != nil { - return fmt.Errorf("apiclient.LoadConfiguration - %w", err) + if loaded, fileErr := apiclient.LoadConfiguration(apiConfigFile); fileErr == nil { + apiConfig = loaded + } else { + // No configuration file (or unreadable): fall through and + // prompt for whatever flags/environment did not provide. + log.Debug().Msgf("API configuration file not loaded: %v", fileErr) + } + } + // A configuration file that exists still may be incomplete — + // ask interactively for anything that is missing. + if missingAPIConfiguration(apiConfig) { + if err := promptMissingAPIConfiguration(&apiConfig); err != nil { + return err } } // Initialize API @@ -2238,9 +2248,19 @@ func cliWrapper(action func(context.Context, *cli.Command) error) func(context.C if apiFlag { if apiConfigFile != "" { log.Debug().Msg("Loading API configuration from file") - apiConfig, err = apiclient.LoadConfiguration(apiConfigFile) - if err != nil { - return fmt.Errorf("apiclient.LoadConfiguration - %w", err) + if loaded, fileErr := apiclient.LoadConfiguration(apiConfigFile); fileErr == nil { + apiConfig = loaded + } else { + // No configuration file (or unreadable): fall through and + // prompt for whatever flags/environment did not provide. + log.Debug().Msgf("API configuration file not loaded: %v", fileErr) + } + } + // A configuration file that exists still may be incomplete — + // ask interactively for anything that is missing. + if missingAPIConfiguration(apiConfig) { + if err := promptMissingAPIConfiguration(&apiConfig); err != nil { + return err } } // Initialize API