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
20 changes: 20 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,30 @@ on:

jobs:
build:
name: Build + Run Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0

- uses: ./.github/actions/build

test-e2e:
name: Run E2E (Testscript) Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'

- name: Build binary
run: make build

- name: Run E2E (Testscript) Tests / Without API
run: |
cd tests/e2e
go test -v
82 changes: 0 additions & 82 deletions .github/workflows/testscript.yml

This file was deleted.

7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@

### Bug fixes

- Fix panic when config commands are used without a default account set #798
- fix(config): panic when used without a default account set #798

### Testing

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


## 1.93.0

Expand Down
96 changes: 81 additions & 15 deletions cmd/config/config_add.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ package config

import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"

"github.com/spf13/cobra"

Expand All @@ -29,11 +33,25 @@ func init() {

newAccount, err := promptAccountInformation()
if err != nil {
// Handle cancellation gracefully without showing error
if errors.Is(err, context.Canceled) || err == io.EOF {
fmt.Fprintln(os.Stderr, "Error: Operation Cancelled")
os.Exit(130) // Standard exit code for SIGINT
}
return err
}

config := &account.Config{Accounts: []account.Account{*newAccount}}

// Get config file path, creating if this is the first account
filePath := exocmd.GConfig.ConfigFileUsed()
if isFirstAccount && filePath == "" {
if filePath, err = createConfigFile(exocmd.DefaultConfigFileName); err != nil {
return err
}
exocmd.GConfig.SetConfigFile(filePath)
}

if isFirstAccount {
// First account: automatically set as default
config.DefaultAccount = newAccount.Name
Expand All @@ -47,7 +65,7 @@ func init() {
}
}

return saveConfig(exocmd.GConfig.ConfigFileUsed(), config)
return saveConfig(filePath, config)
},
})
}
Expand All @@ -70,6 +88,11 @@ func addConfigAccount(firstRun bool) error {

newAccount, err := promptAccountInformation()
if err != nil {
// Handle cancellation gracefully with message
if errors.Is(err, context.Canceled) || err == io.EOF {
fmt.Fprintln(os.Stderr, "Error: Operation Cancelled")
os.Exit(130) // Standard exit code for SIGINT
}
return err
}
config.DefaultAccount = newAccount.Name
Expand All @@ -83,6 +106,36 @@ func addConfigAccount(firstRun bool) error {
return saveConfig(filePath, &config)
}

// readInputWithContext reads a line from stdin with context cancellation support.
// Returns io.EOF if Ctrl+C or Ctrl+D is pressed, allowing graceful cancellation.
// Silent exit behavior matches promptui.Select's interrupt handling.
func readInputWithContext(ctx context.Context, reader *bufio.Reader, prompt string) (string, error) {
fmt.Printf("[+] %s: ", prompt)

inputCh := make(chan struct {
value string
err error
}, 1)

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

select {
case result := <-inputCh:
if result.err != nil {
return "", result.err
}
return strings.TrimSpace(result.value), nil
case <-ctx.Done():
return "", ctx.Err()
}
}

func promptAccountInformation() (*account.Account, error) {
var client *v3.Client

Expand All @@ -91,34 +144,47 @@ func promptAccountInformation() (*account.Account, error) {
reader := bufio.NewReader(os.Stdin)
account := &account.Account{}

apiKey, err := utils.ReadInput(ctx, reader, "API Key", account.Key)
// Prompt for API Key with validation
apiKey, err := readInputWithContext(ctx, reader, "API Key")
if err != nil {
return nil, err
}
if apiKey != account.Key {
account.Key = apiKey
for apiKey == "" {
fmt.Println("API Key cannot be empty")
apiKey, err = readInputWithContext(ctx, reader, "API Key")
if err != nil {
return nil, err
}
}
account.Key = apiKey

secret := account.APISecret()
secretShow := account.APISecret()
if secret != "" && len(secret) > 10 {
secretShow = secret[0:7] + "..."
}
secretKey, err := utils.ReadInput(ctx, reader, "Secret Key", secretShow)
// Prompt for Secret Key with validation
secretKey, err := readInputWithContext(ctx, reader, "Secret Key")
if err != nil {
return nil, err
}
if secretKey != secret && secretKey != secretShow {
account.Secret = secretKey
for secretKey == "" {
fmt.Println("Secret Key cannot be empty")
secretKey, err = readInputWithContext(ctx, reader, "Secret Key")
if err != nil {
return nil, err
}
}
account.Secret = secretKey

name, err := utils.ReadInput(ctx, reader, "Name", account.Name)
// Prompt for Name with validation
name, err := readInputWithContext(ctx, reader, "Name")
if err != nil {
return nil, err
}
if name != "" {
account.Name = name
for name == "" {
fmt.Println("Name cannot be empty")
name, err = readInputWithContext(ctx, reader, "Name")
if err != nil {
return nil, err
}
}
account.Name = name

for {
if a := getAccountByName(account.Name); a == nil {
Expand Down
4 changes: 2 additions & 2 deletions cmd/config/config_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ var configSetCmd = &cobra.Command{
if len(args) < 1 {
return cmd.Usage()
}
if account.GAllAccount == nil {
return fmt.Errorf("no accounts configured")
if account.GAllAccount == nil || len(account.GAllAccount.Accounts) == 0 {
return fmt.Errorf("no accounts configured. Run: exo config (or exo config add)")
}

if a := getAccountByName(args[0]); a == nil {
Expand Down
10 changes: 7 additions & 3 deletions cmd/config/config_show.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,17 @@ Supported output template annotations: %s`,
strings.Join(output.TemplateAnnotations(&configShowOutput{}), ", ")),
Aliases: exocmd.GShowAlias,
RunE: func(cmd *cobra.Command, args []string) error {
if account.GAllAccount == nil {
return fmt.Errorf("no accounts configured")
if account.GAllAccount == nil || len(account.GAllAccount.Accounts) == 0 {
return fmt.Errorf("no accounts configured. Run: exo config (or exo config add)")
}

name := account.CurrentAccount.Name
var name string
if len(args) > 0 {
name = args[0]
} else if account.CurrentAccount != nil && account.CurrentAccount.Name != "" {
name = account.CurrentAccount.Name
} else {
return fmt.Errorf("default account not defined. Please specify an account name or set a default with: exo config set <account-name>")
}

return utils.PrintOutput(showConfig(name))
Expand Down
43 changes: 42 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os/user"
"path"
"path/filepath"
"slices"
"strconv"
"strings"

Expand Down Expand Up @@ -236,6 +237,8 @@ func initConfig() { //nolint:gocyclo
if err := GConfig.ReadInConfig(); err != nil {
if isNonCredentialCmd(nonCredentialCmds...) {
ignoreClientBuild = true
// Set GAllAccount with empty config so config commands can handle gracefully
account.GAllAccount = &account.Config{}
return
}

Expand All @@ -257,15 +260,53 @@ func initConfig() { //nolint:gocyclo
if len(config.Accounts) == 0 {
if isNonCredentialCmd(nonCredentialCmds...) {
ignoreClientBuild = true
// Set GAllAccount so config commands can handle the empty state gracefully
account.GAllAccount = config
return
}

log.Fatalf("no accounts were found into %q", GConfig.ConfigFileUsed())
return
}

// Allow config management commands to run without a default account
// This fixes the circular dependency where 'exo config set' couldn't run
// to set a default account because it required a default account to exist
configManagementCmds := []string{"list", "set", "show"}
isConfigManagementCmd := getCmdPosition("config") == 1
if isConfigManagementCmd && len(os.Args) > 2 {
// Check if the subcommand is a config management command
// Need to find the actual subcommand by skipping flags
for i := 2; i < len(os.Args); i++ {
if !strings.HasPrefix(os.Args[i], "-") {
isConfigManagementCmd = slices.Contains(configManagementCmds, os.Args[i])
break
}
}
} else {
isConfigManagementCmd = false
}

if config.DefaultAccount == "" && gAccountName == "" {
log.Fatalf("default account not defined")
// Allow config management commands to proceed without default account
if isConfigManagementCmd {
ignoreClientBuild = true
// Set GAllAccount so config commands can access the account list
account.GAllAccount = config
return
}

// Provide helpful error message with available accounts
var availableAccounts []string
for _, acc := range config.Accounts {
availableAccounts = append(availableAccounts, acc.Name)
}
if len(availableAccounts) > 0 {
log.Fatalf("default account not defined\n\nSet a default account with: exo config set <account-name>\nAvailable accounts: %s\n\nOr specify an account for this command with: --use-account <account-name>",
strings.Join(availableAccounts, ", "))
} else {
log.Fatalf("default account not defined")
}
}

if gAccountName == "" {
Expand Down
Loading