Skip to content
Open
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
### Testing

- test(testscript): add PTY infrastructure for testing interactive flows and commands #800

- test(testscript): add validation handling in PTY-based interactive flows #801

## 1.93.0

Expand Down
7 changes: 6 additions & 1 deletion cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package config

import (
"fmt"
"io"
"log"
"os"
"path"
Expand Down Expand Up @@ -44,7 +45,8 @@ func configCmdRun(cmd *cobra.Command, _ []string) error {
if err != nil {
switch err {
case promptui.ErrInterrupt:
return nil
fmt.Fprintln(os.Stderr, "Error: Operation Cancelled")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

log.Fatal may do the same behavior, except if you want this specific exitcode. If 130 is a known exit code, we may have constants somewhere to understand those exitcodes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you're right 💭 lemme think this through.
I had some idea to have a consistent behaviour across the cli 🤔

os.Exit(130)
default:
return fmt.Errorf("prompt failed: %s", err)
}
Expand Down Expand Up @@ -241,6 +243,9 @@ func chooseZone(client *v3.Client, zones []string) (string, error) {

_, result, err := prompt.Run()
if err != nil {
if err == promptui.ErrInterrupt {
return "", io.EOF // Return io.EOF to signal cancellation
}
return "", fmt.Errorf("prompt failed: %w", err)
}

Expand Down
55 changes: 54 additions & 1 deletion cmd/config/config_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"strings"

"github.com/manifoldco/promptui"
"github.com/spf13/cobra"

exocmd "github.com/exoscale/cli/cmd"
Expand Down Expand Up @@ -59,9 +60,18 @@ func init() {
fmt.Printf("Set [%s] as default account (first account)\n", newAccount.Name)
} else {
// Additional account: ask user if it should be the new default
if utils.AskQuestion(exocmd.GContext, "Set ["+newAccount.Name+"] as default account?") {
setDefault, err := askSetDefault(newAccount.Name)
if err != nil {
if errors.Is(err, promptui.ErrInterrupt) || err == io.EOF {
fmt.Fprintln(os.Stderr, "Error: Operation Cancelled")
os.Exit(130)
}
return err
}
if setDefault {
config.DefaultAccount = newAccount.Name
exocmd.GConfig.Set("defaultAccount", newAccount.Name)
fmt.Printf("Set [%s] as default account\n", newAccount.Name)
}
}

Expand Down Expand Up @@ -222,3 +232,46 @@ func promptAccountInformation() (*account.Account, error) {

return account, nil
}

// askSetDefault asks whether the new account should become the default.
// Returns true for "y/Y/yes", false for anything else (empty = default No).
//
// This deliberately uses plain bufio line-based I/O rather than promptui.Prompt
// (readline). promptui.Prompt defers its initial PTY render until the first
// keystroke, which deadlocks the settle-based PTY test harness: the test waits
// for output before sending input, but the prompt waits for input before
// producing output. Plain bufio avoids readline entirely; the PTY line
// discipline (cooked mode, restored by the preceding promptui.Select) handles
// '\r' → '\n' translation for us.
func askSetDefault(name string) (bool, error) {
fmt.Printf("[?] Set [%s] as default account? [y/N]: ", name)

ctx := exocmd.GContext
reader := bufio.NewReader(os.Stdin)

resultCh := make(chan struct {
value string
err error
}, 1)
go func() {
value, err := reader.ReadString('\n')
resultCh <- struct {
value string
err error
}{value, err}
}()

select {
case result := <-resultCh:
if result.err == io.EOF {
return false, io.EOF
}
if result.err != nil {
return false, result.err
}
lower := strings.ToLower(strings.TrimSpace(result.value))
return lower == "y" || lower == "yes", nil
case <-ctx.Done():
return false, ctx.Err()
}
}
2 changes: 1 addition & 1 deletion tests/e2e/go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/exoscale/cli/internal/integ
module github.com/exoscale/cli/internal/e2e

go 1.23

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Test cancelling during secret key prompt
# User enters API key but cancels at secret prompt

# Attempt to add account and cancel at secret prompt
! execpty --stdin=inputs exo config add
stderr 'Error: Operation Cancelled'

-- inputs --
EXOtest123
@ctrl+c
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Test cancelling during zone selection
# User enters all credentials but cancels at zone selection

# Attempt to add account and cancel at zone selection
# Wait for zone menu to appear (let API call fail first), then cancel
! execpty --stdin=inputs exo config add
stderr 'Error: Operation Cancelled'

-- inputs --
EXOtest123
secretTest456
TestAccount
@down
@ctrl+c
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Test cancelling with Ctrl+D (EOF) during API key prompt
# Ctrl+D sends EOF signal which should be handled like Ctrl+C

# Attempt to add account and cancel with Ctrl+D
! execpty --stdin=inputs exo config add
stderr 'Error: Operation Cancelled'

-- inputs --
@ctrl+d
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Test: Interactive config add with duplicate name handling
# Tests that the CLI properly rejects duplicate account names and re-prompts.

# Create initial config with one account
mkdir -p .config/exoscale
cp initial-config.toml .config/exoscale/exoscale.toml

# Try to add account with duplicate name, then provide unique name
execpty --stdin=inputs exo config add
stdout 'Name \[ExistingAccount\] already exist'
stdout 'UniqueAccount'

# Verify the new account was added with the corrected name
exec exo config list
stdout 'ExistingAccount'
stdout 'UniqueAccount'

# Verify UniqueAccount was created properly
exec exo config show UniqueAccount
stdout 'UniqueAccount'
stdout 'EXOunique999'

-- inputs --
EXOunique999
secretUnique999
ExistingAccount
UniqueAccount
@enter
n

-- initial-config.toml --
defaultAccount = "ExistingAccount"

[[accounts]]
account = "ExistingAccount"
defaultZone = "ch-gva-2"
key = "EXOexisting456"
name = "ExistingAccount"
secret = "secretExisting456"
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Test: Interactive config add with empty value validation
# Tests that API Key, Secret Key, and Name cannot be left empty.
# The CLI should re-prompt when empty values are submitted.

# Create empty config directory (simulating first-time setup)
mkdir -p .config/exoscale

# Run interactive config add, attempting to submit empty values first
execpty --stdin=inputs exo config add
stdout 'API Key cannot be empty'
stdout 'Secret Key cannot be empty'
stdout 'Name cannot be empty'
stdout '\[TestAccount\] as default account \(first account\)'

# Verify config file was created with correct content
exec exo config show
stdout 'TestAccount'
stdout 'EXOvalid123'

-- inputs --
@enter
EXOvalid123
@enter
validSecret456
@enter
TestAccount
@down
@enter
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Test: Interactive config add making new account the default
# Tests adding a second account and choosing to make it the new default.

# Create initial config with one account
mkdir -p .config/exoscale
cp initial-config.toml .config/exoscale/exoscale.toml

# Add second account and make it default (answer 'y' to prompt)
execpty --stdin=inputs exo config add
stdout 'Set \[NewDefault\] as default account'

# Verify both accounts exist
exec exo config list
stdout 'FirstAccount'
stdout 'NewDefault'

# Verify new account is now default
stdout 'NewDefault\*'
! stdout 'FirstAccount\*'

-- inputs --
EXOnewdefault789
secretNew789
NewDefault
@enter
y

-- initial-config.toml --
defaultAccount = "FirstAccount"

[[accounts]]
account = "FirstAccount"
defaultZone = "ch-gva-2"
key = "EXOfirst123"
name = "FirstAccount"
secret = "secretFirst123"
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Test: Interactive config add for second account (with default prompt)
# Tests adding a second account and being prompted whether to make it the new default.

# Create initial config with one account
mkdir -p .config/exoscale
cp initial-config.toml .config/exoscale/exoscale.toml

# Add second account interactively and decline to make it default
execpty --stdin=inputs exo config add
! stdout 'No Exoscale CLI configuration found'

# Verify both accounts exist
exec exo config list
stdout 'FirstAccount'
stdout 'SecondAccount'

# Verify first account is still default (we answered 'n' to the prompt)
stdout 'FirstAccount\*'
! stdout 'SecondAccount\*'

# Verify second account was added
exec exo config show SecondAccount
stdout 'SecondAccount'
stdout 'EXOsecond456'

-- inputs --
EXOsecond456
secretSecond456
SecondAccount
@enter
n

-- initial-config.toml --
defaultAccount = "FirstAccount"

[[accounts]]
account = "FirstAccount"
defaultZone = "ch-gva-2"
key = "EXOfirst123"
name = "FirstAccount"
secret = "secretFirst123"
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Test: Interactive config add with zone selection navigation
# Tests navigating the zone selection menu with arrow keys.

# Create empty config directory
mkdir -p .config/exoscale

# Add account and navigate down to select ch-dk-2 (second zone in typical list)
# Typical zone order: at-vie-1, at-vie-2, bg-sof-1, ch-dk-2, ch-gva-2...
execpty --stdin=inputs exo config add
stdout 'TestZoneNav'
stdout 'as default account \(first account\)'

# Verify the account was created
exec exo config show
stdout 'TestZoneNav'
stdout 'EXOzonetest111'

# Note: We can't easily verify the selected zone in non-API mode
# as the zone list requires API access. In real scenarios with API,
# this test would verify the defaultZone field in the config.

-- inputs --
EXOzonetest111
secretZone111
TestZoneNav
@down
@down
@down
@enter
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Test: Commands require default account or --use-account flag
# When config exists but no default account is set, most commands should fail
# with a helpful error message, unless --use-account flag is provided.

# Create config with account but NO defaultAccount field
mkdir -p .config/exoscale
cp test-config.toml .config/exoscale/exoscale.toml

# Config management commands work (they don't require default)
exec exo config list
stdout 'Exoscale-Test'

# Config show without account name or default should fail with helpful message
! exec exo config show
stderr 'default account not defined'

# Non-config commands require default account
! exec exo compute instance list
stderr 'default account not defined'
stderr 'Set a default account with: exo config set <account-name>'
stderr 'Available accounts: Exoscale-Test'

# Workaround 1: --use-account flag bypasses the default account requirement
exec exo --use-account Exoscale-Test config show
stdout 'Exoscale-Test'
stdout 'EXOtest123'

# Workaround 2: Set a default account
exec exo config set Exoscale-Test
stdout 'Default profile set to \[Exoscale-Test\]'

# Now commands work without flag
exec exo config show
stdout 'Exoscale-Test'

# Config file without defaultAccount field
-- test-config.toml --
[[accounts]]
name = "Exoscale-Test"
key = "EXOtest123"
secret = "testsecret123"
defaultZone = "ch-gva-2"
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Test cancelling at config menu selection
# User has existing config, opens menu, then cancels

# Create existing config with one account
env HOME=$WORK
mkdir $HOME/.config/exoscale
cp test-config.toml $HOME/.config/exoscale/exoscale.toml

# Open config menu and cancel
! execpty --stdin=inputs exo config
stderr 'Error: Operation Cancelled'

-- test-config.toml --
defaultaccount = "test"

[[accounts]]
name = "test"
key = "EXOtest123"
secret = "secretTest456"
defaultzone = "ch-gva-2"

-- inputs --
@down
@ctrl+c
Loading