Skip to content

Commit 433c156

Browse files
committed
Fix startup profile chooser default handling
1 parent cb274ab commit 433c156

6 files changed

Lines changed: 78 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- **Ansible Toolkit Plugin**: Added a new opt-in `ansible` plugin with integrated toolkit workflows for generating inventory from loaded Proxmox nodes/guests (YAML or INI, compact/expanded styles, optional custom `inventory_vars`), previewing/saving inventory, running `ansible -m ping`, executing `ansible-playbook` with reusable form state, and providing an SSH setup guide. Includes plugin settings for default user/password/key path, limit behavior, and default Ansible extra arguments.
1313

14+
### Fixed
15+
16+
- **Startup Profile Chooser Default Handling**: When `default_profile` is unset, startup now correctly prompts for profile/group selection even if a profile is literally named `default`, and non-interactive launches now return a clear error instead of exiting successfully without starting the app.
17+
1418
## [1.0.20] - 2026-02-28
1519

1620
### Added

internal/bootstrap/bootstrap.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ package bootstrap
77

88
import (
99
"bufio"
10+
"errors"
1011
"flag"
1112
"fmt"
1213
"os"
@@ -62,6 +63,8 @@ type BootstrapResult struct {
6263
InitialGroup string
6364
}
6465

66+
var errSelectionCanceled = errors.New("profile selection canceled")
67+
6568
// ParseFlags parses command line flags and returns bootstrap options.
6669
func ParseFlags() BootstrapOptions {
6770
var configPath, profile string
@@ -262,8 +265,7 @@ func Bootstrap(opts BootstrapOptions) (*BootstrapResult, error) {
262265
} else {
263266
chosen, err := promptProfileSelection(cfg)
264267
if err != nil {
265-
fmt.Println("🚪 Exiting.")
266-
os.Exit(0)
268+
return nil, fmt.Errorf("interactive profile selection failed: %w", err)
267269
}
268270
selectedProfile = chosen
269271
}
@@ -629,6 +631,11 @@ func valueOrDash(value string) string {
629631
// It returns the selected profile or group name, or an error if the user
630632
// cancels or no valid input is provided.
631633
func promptProfileSelection(cfg *config.Config) (string, error) {
634+
stat, err := os.Stdin.Stat()
635+
if err != nil || stat.Mode()&os.ModeCharDevice == 0 {
636+
return "", fmt.Errorf("%w: no interactive terminal available", errSelectionCanceled)
637+
}
638+
632639
// Collect groups (sorted)
633640
groups := cfg.GetGroups()
634641
groupNames := make([]string, 0, len(groups))
@@ -696,7 +703,7 @@ func promptProfileSelection(cfg *config.Config) (string, error) {
696703
if !scanner.Scan() {
697704
// EOF or Ctrl+C
698705
fmt.Println()
699-
return "", fmt.Errorf("selection canceled")
706+
return "", errSelectionCanceled
700707
}
701708
line := strings.TrimSpace(scanner.Text())
702709
n, err := strconv.Atoi(line)

internal/bootstrap/bootstrap_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package bootstrap
22

33
import (
4+
"errors"
45
"os"
56
"path/filepath"
67
"strings"
@@ -100,3 +101,56 @@ func TestFormatProfileAndGroupListNoProfiles(t *testing.T) {
100101
t.Fatalf("expected empty selected value in output: %q", output)
101102
}
102103
}
104+
105+
func TestPromptProfileSelectionRequiresInteractiveTerminal(t *testing.T) {
106+
t.Setenv("PVETUI_PROFILE", "")
107+
108+
cfg := &config.Config{
109+
Profiles: map[string]config.ProfileConfig{
110+
"dev": {
111+
Addr: "https://dev.example",
112+
User: "root@pam",
113+
},
114+
"prod": {
115+
Addr: "https://prod.example",
116+
User: "root@pam",
117+
},
118+
},
119+
}
120+
121+
_, err := promptProfileSelection(cfg)
122+
if !errors.Is(err, errSelectionCanceled) {
123+
t.Fatalf("expected errSelectionCanceled, got %v", err)
124+
}
125+
if err == nil {
126+
t.Fatal("expected selection cancellation error")
127+
}
128+
}
129+
130+
func TestBootstrapReturnsErrorWhenSelectionNeedsTTY(t *testing.T) {
131+
configPath := filepath.Join(t.TempDir(), "config.yaml")
132+
configData := `profiles:
133+
dev:
134+
addr: https://dev.example
135+
user: root@pam
136+
password: secret
137+
prod:
138+
addr: https://prod.example
139+
user: root@pam
140+
password: secret
141+
`
142+
if err := os.WriteFile(configPath, []byte(configData), 0o600); err != nil {
143+
t.Fatalf("write config: %v", err)
144+
}
145+
146+
result, err := Bootstrap(BootstrapOptions{ConfigPath: configPath})
147+
if result != nil {
148+
t.Fatalf("expected nil result when bootstrap cannot prompt, got %+v", result)
149+
}
150+
if err == nil {
151+
t.Fatal("expected bootstrap error when no interactive terminal is available")
152+
}
153+
if !strings.Contains(err.Error(), "interactive profile selection failed") {
154+
t.Fatalf("expected interactive selection error, got %v", err)
155+
}
156+
}

internal/config/config.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,8 +330,7 @@ func ValidateKeyBindings(kb KeyBindings) error {
330330
// }
331331
func NewConfig() *Config {
332332
config := &Config{
333-
Profiles: make(map[string]ProfileConfig),
334-
DefaultProfile: "default",
333+
Profiles: make(map[string]ProfileConfig),
335334
// Read environment variables for legacy fields
336335
Addr: os.Getenv("PVETUI_ADDR"),
337336
User: os.Getenv("PVETUI_USER"),
@@ -1243,8 +1242,8 @@ func (c *Config) SetDefaults() {
12431242
c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile = ExpandHomePath(c.Plugins.Ansible.Bootstrap.SSHPublicKeyFile)
12441243
}
12451244

1246-
// ShowIcons defaults to true (icons enabled)
1247-
// No explicit default needed since it's already set in NewConfig()
1245+
// ShowIcons defaults to true (icons enabled).
1246+
// The zero value is treated as enabled unless explicitly set to false by env/config.
12481247
}
12491248

12501249
// ExpandHomePath expands a leading ~ in paths using the current user's home directory.

internal/config/config_test.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,12 @@ func TestConfig_SetDefaults(t *testing.T) {
678678
assert.Equal(t, "Ctrl+f", config.KeyBindings.AdvancedGuestFilter)
679679
}
680680

681+
func TestNewConfigDoesNotSeedDefaultProfile(t *testing.T) {
682+
cfg := NewConfig()
683+
684+
assert.Empty(t, cfg.DefaultProfile)
685+
}
686+
681687
func TestConfig_MergeWithFile_AdvancedGuestFilterBinding(t *testing.T) {
682688
tempFile, err := os.CreateTemp("", "config-advanced-filter-key-*.yaml")
683689
require.NoError(t, err)

internal/ui/components/refresh.go

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -151,20 +151,9 @@ func (a *App) doManualRefresh(token uint64) {
151151
}()
152152
}
153153

154-
// fastRefresh loads basic node/VM names immediately and enriches details in the background.
155-
// This is the public entry point; it acquires the refresh guard and delegates to doFastRefresh.
156-
// Used for normal fast-refresh (failover callbacks, etc.) where a refresh may already be running.
157-
func (a *App) fastRefresh() {
158-
token, ok := a.startRefresh()
159-
if !ok {
160-
return
161-
}
162-
a.doFastRefresh(token)
163-
}
164-
165154
// doFastRefresh is the implementation of fast refresh.
166155
// Callers that already hold a generation token (e.g. profile switches via forceNewRefresh)
167-
// should call this directly instead of fastRefresh.
156+
// should call this directly instead of acquiring a new token first.
168157
// This is used for profile switching where perceived speed matters most —
169158
// the user sees node and guest lists right away, then details (CPU, filesystems, guest agent)
170159
// fill in progressively.

0 commit comments

Comments
 (0)