From 4d5db6f6a3b4db9139fac8f8c335f940655b6119 Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 10 Apr 2026 14:48:28 +0300 Subject: [PATCH 1/2] feat: upgrade to verdagostack v1.3.0 composite wizard model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade to verdagostack v1.3.0 which introduces the composite wizard model — a single Bubble Tea program runs for the entire wizard lifetime instead of creating separate programs per prompt. This enables proper Ctrl+C handling via key bindings intercepted at the composite level. Changes: - Upgrade verdagostack dependency from v1.2.0 to v1.3.0 - Enable WithExitConfirmation() on vm create, template create, and auth login wizards (Ctrl+C shows "Exit wizard?" confirm prompt) - Update wizard tests to use WithTestResults() instead of the old tui/testing.Prompter (composite model uses result channels) - Extract buildInstanceTypeChoices() to reduce cyclomatic complexity - Add kindCPU constant, use billingTypeSpot constant consistently - Add clear error message when no instance types are available Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 4 +- go.sum | 8 +-- internal/verda-cli/cmd/auth/login.go | 2 +- internal/verda-cli/cmd/auth/wizard_test.go | 28 ++++++----- internal/verda-cli/cmd/vm/create.go | 2 +- internal/verda-cli/cmd/vm/wizard.go | 57 +++++----------------- internal/verda-cli/cmd/vm/wizard_cache.go | 53 ++++++++++++++++++++ internal/verda-cli/cmd/vm/wizard_test.go | 44 +++++++++-------- 8 files changed, 112 insertions(+), 86 deletions(-) diff --git a/go.mod b/go.mod index bc9b6f1..b6b8b3d 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/verda-cloud/verdacloud-sdk-go v1.4.2 - github.com/verda-cloud/verdagostack v1.2.0 + github.com/verda-cloud/verdagostack v1.3.0 go.yaml.in/yaml/v3 v3.0.4 ) @@ -53,5 +53,5 @@ require ( go.uber.org/zap v1.27.1 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/text v0.35.0 // indirect ) diff --git a/go.sum b/go.sum index 7f29015..ffa11ad 100644 --- a/go.sum +++ b/go.sum @@ -104,8 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= -github.com/verda-cloud/verdagostack v1.2.0 h1:HdVFXWyfEMq17ldn3+FrePYYB5Jdquz9D/Y0V+4d2BE= -github.com/verda-cloud/verdagostack v1.2.0/go.mod h1:h9XR9uCYBYauRyGF4NLlScD5bC2UEMxMkqg6fUVjyDo= +github.com/verda-cloud/verdagostack v1.3.0 h1:NxW5OaE79tbc9pemy/Zasjqw08IuvNr0ivlpe0VP91Q= +github.com/verda-cloud/verdagostack v1.3.0/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -124,8 +124,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/verda-cli/cmd/auth/login.go b/internal/verda-cli/cmd/auth/login.go index 1844afb..e83f379 100644 --- a/internal/verda-cli/cmd/auth/login.go +++ b/internal/verda-cli/cmd/auth/login.go @@ -71,7 +71,7 @@ func NewCmdLogin(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command RunE: func(cmd *cobra.Command, args []string) error { if strings.TrimSpace(opts.ClientID) == "" || strings.TrimSpace(opts.ClientSecret) == "" { flow := buildLoginFlow(opts) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) if err := engine.Run(cmd.Context(), flow); err != nil { return err } diff --git a/internal/verda-cli/cmd/auth/wizard_test.go b/internal/verda-cli/cmd/auth/wizard_test.go index e78d09c..4d0ea6b 100644 --- a/internal/verda-cli/cmd/auth/wizard_test.go +++ b/internal/verda-cli/cmd/auth/wizard_test.go @@ -2,9 +2,9 @@ package auth import ( "context" + "io" "testing" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) @@ -16,14 +16,16 @@ func TestBuildLoginFlowHappyPath(t *testing.T) { BaseURL: defaultBaseURL, } - mock := tuitest.New() - mock.AddTextInput("staging") // profile - mock.AddTextInput("https://staging-api.verda.com") // base-url - mock.AddTextInput("my-id") // client-id - mock.AddPassword("my-secret") // client-secret - flow := buildLoginFlow(opts) - engine := wizard.NewEngine(mock, nil) + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.TextResult("staging"), // profile + wizard.TextResult("https://staging-api.verda.com"), // base-url + wizard.TextResult("my-id"), // client-id + wizard.TextResult("my-secret"), // client-secret (password prompt returns text too) + ), + ) if err := engine.Run(context.Background(), flow); err != nil { t.Fatalf("wizard Run failed: %v", err) @@ -53,11 +55,13 @@ func TestBuildLoginFlowWithPresetFlags(t *testing.T) { } // Only client-secret needs prompting (profile, base-url, client-id are preset via IsSet). - mock := tuitest.New() - mock.AddPassword("the-secret") - flow := buildLoginFlow(opts) - engine := wizard.NewEngine(mock, nil) + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.TextResult("the-secret"), // client-secret + ), + ) if err := engine.Run(context.Background(), flow); err != nil { t.Fatalf("wizard Run failed: %v", err) diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 6c869c8..12d30f7 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -269,7 +269,7 @@ func missingCreateFlags(opts *createOptions) []string { func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy, ioStreams.ErrOut) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) return engine.Run(ctx, flow) } diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index e1de7ca..798c86e 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -19,6 +19,7 @@ import ( const ( billingTypeSpot = "spot" kindGPU = "gpu" + kindCPU = "cpu" contractPayAsYouGo = "PAY_AS_YOU_GO" contractSpot = "SPOT" @@ -78,7 +79,7 @@ func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil func runTemplateWizardWithOpts(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) (*TemplateResult, error) { flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate, ioStreams.ErrOut) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) if err := engine.Run(ctx, flow); err != nil { return nil, err } @@ -104,7 +105,7 @@ func optsToTemplateResult(opts *createOptions) *TemplateResult { Description: opts.templateDescription, } if opts.IsSpot { - result.BillingType = "spot" + result.BillingType = billingTypeSpot } else { result.BillingType = billingTypeOnDemand } @@ -258,7 +259,7 @@ func stepKind(opts *createOptions) wizard.Step { Required: true, Loader: wizard.StaticChoices( wizard.Choice{Label: "GPU", Value: kindGPU, Description: "GPU-accelerated instances"}, - wizard.Choice{Label: "CPU", Value: "cpu", Description: "CPU-only instances"}, + wizard.Choice{Label: "CPU", Value: kindCPU, Description: "CPU-only instances"}, ), Default: func(_ map[string]any) any { return opts.Kind }, Setter: func(v any) { opts.Kind = v.(string) }, @@ -318,51 +319,17 @@ func stepInstanceType(getClient clientFunc, cache *apiCache, opts *createOptions } } - var choices []wizard.Choice - for i := range types { - t := &types[i] - if !matchesKind(t.InstanceType, kind) { - continue + choices := buildInstanceTypeChoices(types, kind, isSpot, availLocs, cache) + if len(choices) == 0 { + kindLabel := "GPU" + if kind == kindCPU { + kindLabel = "CPU" } - if availLocs != nil && len(availLocs[t.InstanceType]) == 0 { - continue // skip unavailable instance types (deploy mode only) - } - totalPrice := float64(t.PricePerHour) + pricingLabel := "on-demand" if isSpot { - totalPrice = float64(t.SpotPrice) - } - units := instanceUnits(t) - var priceStr string - if units > 1 { - unitLabel := unitLabelGPU - if t.GPU.NumberOfGPUs == 0 { - unitLabel = unitLabelVCPU - } - perUnit := totalPrice / float64(units) - priceStr = fmt.Sprintf("$%.3f/%s/hr $%.3f/hr", perUnit, unitLabel, totalPrice) - } else { - priceStr = fmt.Sprintf("$%.3f/hr", totalPrice) + pricingLabel = "spot" } - label := fmt.Sprintf("%s — %s, %s %s", - t.InstanceType, formatGPU(t), formatMemory(t), priceStr) - var desc string - if availLocs != nil { - locs := availLocs[t.InstanceType] - locNames := make([]string, len(locs)) - for j, code := range locs { - if loc, ok := cache.locations[code]; ok { - locNames[j] = loc.Code - } else { - locNames[j] = code - } - } - desc = fmt.Sprintf("[%s]", strings.Join(locNames, ", ")) - } - choices = append(choices, wizard.Choice{ - Label: label, - Value: t.InstanceType, - Description: desc, - }) + return nil, fmt.Errorf("no %s %s instances are available right now — try a different compute type or billing type", kindLabel, pricingLabel) } return choices, nil }, diff --git a/internal/verda-cli/cmd/vm/wizard_cache.go b/internal/verda-cli/cmd/vm/wizard_cache.go index 0e804cd..379870b 100644 --- a/internal/verda-cli/cmd/vm/wizard_cache.go +++ b/internal/verda-cli/cmd/vm/wizard_cache.go @@ -148,6 +148,59 @@ func loadAvailableLocations(ctx context.Context, cache *apiCache, getClient clie return choices, nil } +// --- Instance type choices --- + +// buildInstanceTypeChoices filters and formats instance types into wizard choices. +func buildInstanceTypeChoices(types []verda.InstanceTypeInfo, kind string, isSpot bool, availLocs map[string][]string, cache *apiCache) []wizard.Choice { + var choices []wizard.Choice + for i := range types { + t := &types[i] + if !matchesKind(t.InstanceType, kind) { + continue + } + if availLocs != nil && len(availLocs[t.InstanceType]) == 0 { + continue + } + totalPrice := float64(t.PricePerHour) + if isSpot { + totalPrice = float64(t.SpotPrice) + } + units := instanceUnits(t) + var priceStr string + if units > 1 { + unitLabel := unitLabelGPU + if t.GPU.NumberOfGPUs == 0 { + unitLabel = unitLabelVCPU + } + perUnit := totalPrice / float64(units) + priceStr = fmt.Sprintf("$%.3f/%s/hr $%.3f/hr", perUnit, unitLabel, totalPrice) + } else { + priceStr = fmt.Sprintf("$%.3f/hr", totalPrice) + } + label := fmt.Sprintf("%s — %s, %s %s", + t.InstanceType, formatGPU(t), formatMemory(t), priceStr) + var desc string + if availLocs != nil { + locs := availLocs[t.InstanceType] + locNames := make([]string, len(locs)) + for j, code := range locs { + if loc, ok := cache.locations[code]; ok { + locNames[j] = loc.Code + } else { + locNames[j] = code + } + } + desc = fmt.Sprintf("[%s]", strings.Join(locNames, ", ")) + } + choices = append(choices, wizard.Choice{ + Label: label, + Value: t.InstanceType, + Description: desc, + }) + } + return choices +} + // --- Helpers --- // instanceUnits returns the number of billable units (GPUs or vCPUs). diff --git a/internal/verda-cli/cmd/vm/wizard_test.go b/internal/verda-cli/cmd/vm/wizard_test.go index 4030293..b60ea16 100644 --- a/internal/verda-cli/cmd/vm/wizard_test.go +++ b/internal/verda-cli/cmd/vm/wizard_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" - tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) @@ -27,20 +26,21 @@ func TestBuildCreateFlowHappyPath(t *testing.T) { } // The wizard will prompt for: billing-type, kind, os-volume-size, - // hostname, description. - mock := tuitest.New() - mock.AddSelect(0) // billing-type: On-Demand - mock.AddSelect(0) // kind: GPU - mock.AddTextInput("100") // os-volume-size - mock.AddTextInput("my-gpu") // hostname - mock.AddTextInput("") // description (use default = hostname) - mock.AddConfirm(true) // confirm deploy - - // errClient returns error — API steps skipped via IsSet, confirm step handles error gracefully. + // hostname, description, confirm-deploy. ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) - engine := wizard.NewEngine(mock, nil) + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.SelectResult(0), // billing-type: On-Demand + wizard.SelectResult(0), // kind: GPU + wizard.TextResult("100"), // os-volume-size + wizard.TextResult("my-gpu"), // hostname + wizard.TextResult(""), // description (use default = hostname) + wizard.ConfirmResult(true), // confirm deploy + ), + ) if err := engine.Run(ctx, flow); err != nil { t.Fatalf("wizard Run failed: %v", err) @@ -75,18 +75,20 @@ func TestBuildCreateFlowSpotSkipsContract(t *testing.T) { VolumeSpecs: []string{"data:100:NVMe"}, // pre-fill so storage step is skipped } - mock := tuitest.New() - mock.AddSelect(1) // billing-type: Spot Instance - mock.AddSelect(0) // kind: GPU - mock.AddTextInput("50") // os-volume-size - mock.AddTextInput("spot-vm") // hostname - mock.AddTextInput("") // description - mock.AddConfirm(true) // confirm deploy - ctx := context.Background() errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard) - engine := wizard.NewEngine(mock, nil) + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.SelectResult(1), // billing-type: Spot Instance + wizard.SelectResult(0), // kind: GPU + wizard.TextResult("50"), // os-volume-size + wizard.TextResult("spot-vm"), // hostname + wizard.TextResult(""), // description + wizard.ConfirmResult(true), // confirm deploy + ), + ) if err := engine.Run(ctx, flow); err != nil { t.Fatalf("wizard Run failed: %v", err) From 97133317ffb09efbdcfe98e8fbda44d6fe5f1d55 Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 10 Apr 2026 14:51:57 +0300 Subject: [PATCH 2/2] fix: pre-allocate choices slice in buildInstanceTypeChoices Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/verda-cli/cmd/vm/wizard_cache.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/verda-cli/cmd/vm/wizard_cache.go b/internal/verda-cli/cmd/vm/wizard_cache.go index 379870b..2510b32 100644 --- a/internal/verda-cli/cmd/vm/wizard_cache.go +++ b/internal/verda-cli/cmd/vm/wizard_cache.go @@ -152,7 +152,7 @@ func loadAvailableLocations(ctx context.Context, cache *apiCache, getClient clie // buildInstanceTypeChoices filters and formats instance types into wizard choices. func buildInstanceTypeChoices(types []verda.InstanceTypeInfo, kind string, isSpot bool, availLocs map[string][]string, cache *apiCache) []wizard.Choice { - var choices []wizard.Choice + choices := make([]wizard.Choice, 0, len(types)) for i := range types { t := &types[i] if !matchesKind(t.InstanceType, kind) {