Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions cmd/cli/api_prompt.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
40 changes: 40 additions & 0 deletions cmd/cli/api_prompt_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
32 changes: 26 additions & 6 deletions cmd/cli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading