From 8379bcfccec5421621e337248a4c73441534be39 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 2 Jun 2026 11:32:41 -0700 Subject: [PATCH 1/2] feat(application): add name, tos and plan selection during application creation --- pkg/cmd/application/create/create.go | 325 +++++++++++- pkg/cmd/application/create/create_test.go | 506 +++++++++++++++++++ pkg/cmd/application/planchange/planchange.go | 13 +- pkg/cmd/shared/apputil/plan.go | 124 +++++ 4 files changed, 944 insertions(+), 24 deletions(-) create mode 100644 pkg/cmd/application/create/create_test.go create mode 100644 pkg/cmd/shared/apputil/plan.go diff --git a/pkg/cmd/application/create/create.go b/pkg/cmd/application/create/create.go index 642cea87..ed623900 100644 --- a/pkg/cmd/application/create/create.go +++ b/pkg/cmd/application/create/create.go @@ -2,7 +2,9 @@ package create import ( "fmt" + "strings" + "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -12,6 +14,8 @@ import ( "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" + pkgopen "github.com/algolia/cli/pkg/open" + "github.com/algolia/cli/pkg/prompt" "github.com/algolia/cli/pkg/validators" ) @@ -22,12 +26,17 @@ type CreateOptions struct { Name string Region string ProfileName string + Plan string Default bool DryRun bool + AcceptTerms bool + + nameProvided bool PrintFlags *cmdutil.PrintFlags NewDashboardClient func(clientID string) *dashboard.Client + Browser func(string) error } func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { @@ -38,6 +47,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { NewDashboardClient: func(clientID string) *dashboard.Client { return dashboard.NewClient(clientID) }, + Browser: pkgopen.Browser, } cmd := &cobra.Command{ @@ -45,35 +55,44 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { Short: "Create a new Algolia application", Long: heredoc.Doc(` Create a new Algolia application and optionally configure it as a CLI profile. - Requires an active session (run "algolia auth login" first). - `), + Requires an active session (run "algolia auth login" first).`), Example: heredoc.Doc(` - # Create an application interactively + # Create an application interactively (prompts for name, plan, and terms) $ algolia application create - # Create with specific options - $ algolia application create --name "My App" --region CA + # Create a Free application non-interactively + $ algolia application create --name "My App" --region CA --accept-terms + + # Create on a paid plan (requires a payment method on file) + $ algolia application create --name "My App" --region CA --plan grow --accept-terms - # Create and set as default profile - $ algolia application create --name "My App" --region CA --default + # Create and set the new profile as the default + $ algolia application create --name "My App" --region CA --accept-terms --default # Preview what would be created without actually creating it - $ algolia application create --name "My App" --region CA --dry-run + $ algolia application create --name "My App" --region CA --plan grow --dry-run `), Args: validators.NoArgs(), Annotations: map[string]string{ "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { + opts.nameProvided = cmd.Flags().Changed("name") return runCreateCmd(opts) }, } cmd.Flags().StringVar(&opts.Name, "name", "My First Application", "Name for the application") - cmd.Flags().StringVar(&opts.Region, "region", "", "Region code (e.g. CA, US, EU)") - cmd.Flags().StringVar(&opts.ProfileName, "profile-name", "", "Name for the CLI profile (defaults to app name)") + cmd.Flags().StringVar(&opts.Region, "region", "", "Region code (e.g. EU, UK, USC, USE, USW)") + cmd.Flags(). + StringVar(&opts.ProfileName, "profile-name", "", "Name for the CLI profile (defaults to app name)") + cmd.Flags(). + StringVar(&opts.Plan, "plan", "", "Self-serve plan to create the application on (free, grow, grow-plus)") cmd.Flags().BoolVar(&opts.Default, "default", false, "Set the new profile as the default") - cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Preview the create request without sending it") + cmd.Flags(). + BoolVar(&opts.DryRun, "dry-run", false, "Preview the create request without sending it") + cmd.Flags(). + BoolVarP(&opts.AcceptTerms, "accept-terms", "y", false, "Accept the selected plan's terms of service (required in non-interactive mode)") opts.PrintFlags.AddFlags(cmd) @@ -81,11 +100,23 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command { } func runCreateCmd(opts *CreateOptions) error { + cs := opts.IO.ColorScheme() + + name, err := resolveName(opts) + if err != nil { + return err + } + if opts.DryRun { + planLabel := opts.Plan + if planLabel == "" { + planLabel = dashboard.PlanTypeFree + } summary := map[string]any{ "action": "create_application", - "name": opts.Name, + "name": name, "region": opts.Region, + "plan": planLabel, "default": opts.Default, "dryRun": true, } @@ -93,23 +124,115 @@ func runCreateCmd(opts *CreateOptions) error { opts.IO, opts.PrintFlags, summary, - fmt.Sprintf("Dry run: would create application %q in region %q", opts.Name, opts.Region), + fmt.Sprintf( + "Dry run: would create application %q in region %q on the %q plan", + name, + opts.Region, + planLabel, + ), ) } client := opts.NewDashboardClient(auth.OAuthClientID()) - accessToken, err := auth.EnsureAuthenticated(opts.IO, client) + token, err := auth.EnsureAuthenticated(opts.IO, client) + if err != nil { + return err + } + + var plans []dashboard.Plan + if err := callWithReauth(opts.IO, client, &token, "Fetching plans", func(t string) error { + var e error + plans, e = client.GetSelfServePlans(t) + return e + }); err != nil { + return err + } + if len(plans) == 0 { + return fmt.Errorf("no self-serve plans are available") + } + + // Best-effort: continue without billing status if /1/user fails. + var user *dashboard.DashboardUser + if err := callWithReauth(opts.IO, client, &token, "Checking account", func(t string) error { + var e error + user, e = client.GetUser(t) + return e + }); err != nil { + user = nil + } + + target, err := selectPlan(opts, plans, user) + if err != nil { + return err + } + + if !target.IsFree() { + billingMissing := !apputil.PlanAvailable(plans, target.ID) || + (user != nil && !user.HasPaymentMethod) + if billingMissing { + return offerBilling(opts, client, *target) + } + } + + accepted, err := confirmToS(opts, *target) if err != nil { return err } + if !accepted { + fmt.Fprintf(opts.IO.Out, "%s Aborted; no application was created.\n", cs.WarningIcon()) + return nil + } - appDetails, err := apputil.CreateAndFetchApplication(opts.IO, client, accessToken, opts.Region, opts.Name) + appDetails, err := apputil.CreateAndFetchApplication(opts.IO, client, token, opts.Region, name) if err != nil { return err } - if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { + if !target.IsFree() { + if err := callWithReauth(opts.IO, client, &token, "Applying plan", func(t string) error { + _, e := client.ChangeApplicationPlan(t, appDetails.ID, target.ID) + return e + }); err != nil { + fmt.Fprintf( + opts.IO.ErrOut, + "%s Application %s was created on the Free plan, but applying the %s plan failed: %v\n", + cs.WarningIcon(), + cs.Bold(appDetails.ID), + cs.Bold(target.Name), + err, + ) + fmt.Fprintf( + opts.IO.ErrOut, + " Add a payment method if needed, then retry with: algolia application upgrade --plan %s\n", + target.ID, + ) + if !opts.structuredOutput() { + _ = apputil.ConfigureProfile( + opts.IO, + opts.Config, + appDetails, + opts.ProfileName, + opts.Default, + ) + } + return fmt.Errorf( + "failed to apply the %q plan to application %s: %w", + target.Name, + appDetails.ID, + err, + ) + } + fmt.Fprintf( + opts.IO.Out, + "%s Application %s created on the %s plan.\n", + cs.SuccessIcon(), + cs.Bold(appDetails.ID), + cs.Bold(target.Name), + ) + } + + if opts.structuredOutput() { p, err := opts.PrintFlags.ToPrinter() if err != nil { return err @@ -117,5 +240,173 @@ func runCreateCmd(opts *CreateOptions) error { return p.Print(opts.IO, appDetails) } - return apputil.ConfigureProfile(opts.IO, opts.Config, appDetails, opts.ProfileName, opts.Default) + return apputil.ConfigureProfile( + opts.IO, + opts.Config, + appDetails, + opts.ProfileName, + opts.Default, + ) +} + +func (opts *CreateOptions) structuredOutput() bool { + return opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil +} + +// resolveName returns the application name, prompting when interactive and --name is unset. +func resolveName(opts *CreateOptions) (string, error) { + if opts.nameProvided || !opts.IO.CanPrompt() { + return opts.Name, nil + } + + var name string + if err := prompt.SurveyAskOne( + &survey.Input{ + Message: "Name:", + Default: opts.Name, + }, + &name, + ); err != nil { + return "", err + } + if name == "" { + name = opts.Name + } + return name, nil +} + +// selectPlan resolves the target plan from --plan or an interactive picker. +func selectPlan( + opts *CreateOptions, + plans []dashboard.Plan, + user *dashboard.DashboardUser, +) (*dashboard.Plan, error) { + if opts.Plan != "" { + target, err := apputil.ResolvePlan(plans, opts.Plan) + if err == nil { + return target, nil + } + if paid := apputil.KnownPaidPlan(opts.Plan); paid != nil { + return paid, nil + } + return nil, err + } + + if !opts.IO.CanPrompt() { + free := apputil.FindFreePlan(plans) + if free == nil { + return nil, fmt.Errorf( + "no free plan is available; pass --plan to choose one of: %s", + strings.Join(apputil.PlanChoices(plans), ", "), + ) + } + return free, nil + } + + hideNonFree := user != nil && !user.HasPaymentMethod + candidates := apputil.SelectablePlans(plans, hideNonFree) + if len(candidates) == 0 { + return nil, fmt.Errorf("no self-serve plans are available") + } + if hideNonFree { + fmt.Fprintln( + opts.IO.Out, + "No payment method on file — only the Free plan is available. Add billing in the Algolia dashboard to unlock paid plans.", + ) + } + if len(candidates) == 1 { + return &candidates[0], nil + } + return apputil.PickPlan(candidates) +} + +// confirmToS shows the plan's terms and returns whether they were accepted. +func confirmToS(opts *CreateOptions, plan dashboard.Plan) (bool, error) { + cs := opts.IO.ColorScheme() + + terms := plan.AcceptTerms + if terms == "" { + terms = fmt.Sprintf("By proceeding, you accept the Algolia %s Plan terms.", plan.Name) + } + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", terms) + + if opts.AcceptTerms { + fmt.Fprintf(opts.IO.Out, "%s Terms accepted via --accept-terms.\n", cs.SuccessIcon()) + return true, nil + } + + if !opts.IO.CanPrompt() { + return false, cmdutil.FlagErrorf( + "the plan terms must be accepted in non-interactive mode; pass --accept-terms to confirm", + ) + } + + accepted := true + if err := prompt.Confirm("Do you accept these terms and want to create the application?", &accepted); err != nil { + return false, err + } + return accepted, nil +} + +// offerBilling tells the user a paid plan needs billing and offers the billing page. +func offerBilling(opts *CreateOptions, client *dashboard.Client, plan dashboard.Plan) error { + cs := opts.IO.ColorScheme() + url := client.DashboardURL + "/account/billing/details" + + fmt.Fprintf( + opts.IO.Out, + "\nThe %s plan requires a payment method on file before a paid application can be provisioned.\nThe CLI can't collect card details.\n", + cs.Bold(plan.Name), + ) + + if opts.IO.CanPrompt() && opts.IO.IsStdoutTTY() { + browser := opts.Browser + if browser == nil { + browser = pkgopen.Browser + } + open := true + if err := prompt.Confirm("Open the billing page to add a payment method?", &open); err != nil { + return err + } + if !open { + return nil + } + fmt.Fprintf(opts.IO.Out, "Opening %s\n", cs.Bold(url)) + return browser(url) + } + + fmt.Fprintf( + opts.IO.Out, + "Add a payment method here, then re-run with --plan %s:\n%s\n", + plan.ID, + url, + ) + return fmt.Errorf("the %q plan requires a payment method; none is on file", plan.Name) +} + +// callWithReauth runs fn, re-authenticating once and retrying on an expired session. +func callWithReauth( + io *iostreams.IOStreams, + client *dashboard.Client, + token *string, + label string, + fn func(token string) error, +) error { + io.StartProgressIndicatorWithLabel(label) + err := fn(*token) + io.StopProgressIndicator() + if err == nil { + return nil + } + + newToken, reAuthErr := auth.ReauthenticateIfExpired(io, client, err) + if reAuthErr != nil { + return reAuthErr + } + *token = newToken + + io.StartProgressIndicatorWithLabel(label) + err = fn(*token) + io.StopProgressIndicator() + return err } diff --git a/pkg/cmd/application/create/create_test.go b/pkg/cmd/application/create/create_test.go new file mode 100644 index 00000000..097c2adb --- /dev/null +++ b/pkg/cmd/application/create/create_test.go @@ -0,0 +1,506 @@ +package create + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zalando/go-keyring" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/test" +) + +// seedToken installs an in-memory keyring with a valid token. +func seedToken(t *testing.T) { + t.Helper() + keyring.MockInit() + require.NoError(t, auth.SaveToken(&dashboard.OAuthTokenResponse{ + AccessToken: "test-token", + ExpiresIn: 3600, + CreatedAt: time.Now().Unix(), + })) +} + +func samplePlanTemplates() []dashboard.PlanTemplateResource { + return []dashboard.PlanTemplateResource{ + { + ID: "build", + Type: "plan_template", + Attributes: dashboard.PlanTemplateAttributes{ + Name: "Build", + Description: "Free forever Search & Discovery API.", + Type: "free", + Configuration: dashboard.PlanTemplateConfiguration{ + Plan: "build", + AcceptTerms: "Build terms", + }, + }, + }, + { + ID: "grow", + Type: "plan_template", + Attributes: dashboard.PlanTemplateAttributes{ + Name: "Grow", + Description: "Best-in-class Search & Discovery API.", + Type: "freeform", + Freeform: "$0.50 / 1,000 Requests", + Configuration: dashboard.PlanTemplateConfiguration{ + Plan: "grow", + AcceptTerms: "Grow terms", + }, + }, + }, + { + ID: "grow-plus", + Type: "plan_template", + Attributes: dashboard.PlanTemplateAttributes{ + Name: "Grow Plus", + Description: "AI-powered Search & Discovery API.", + Type: "freeform", + Freeform: "$1.75 / 1,000 Requests", + Configuration: dashboard.PlanTemplateConfiguration{ + Plan: "grow-plus", + AcceptTerms: "Grow Plus terms", + }, + }, + }, + } +} + +type createServer struct { + *httptest.Server + createCalls int + patchCalls int + lastPlan string + failPatch bool + // freeOnly returns only the free plan, mirroring the API when no billing is on file. + freeOnly bool +} + +// newServer spins up a dashboard stub. An empty userJSON makes /1/user fail. +func newServer(t *testing.T, userJSON string) *createServer { + t.Helper() + srv := &createServer{} + + appResponse := dashboard.SingleApplicationResponse{ + Data: dashboard.ApplicationResource{ + ID: "APP1", + Type: "application", + Attributes: dashboard.ApplicationAttributes{ + ApplicationID: "APP1", + Name: "My App", + }, + }, + } + + mux := http.NewServeMux() + mux.HandleFunc( + "/1/plan-templates/self-serve", + func(w http.ResponseWriter, _ *http.Request) { + templates := samplePlanTemplates() + if srv.freeOnly { + templates = templates[:1] // only the free "build" template + } + require.NoError(t, json.NewEncoder(w).Encode(dashboard.PlanTemplatesResponse{ + Data: templates, + })) + }, + ) + mux.HandleFunc("/1/user", func(w http.ResponseWriter, _ *http.Request) { + if userJSON == "" { + w.WriteHeader(http.StatusInternalServerError) + return + } + require.NoError(t, json.NewEncoder(w).Encode(json.RawMessage(userJSON))) + }) + mux.HandleFunc("/1/hosting/regions", func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(dashboard.RegionsResponse{ + RegionCodes: []dashboard.Region{{Code: "CA", Name: "Canada"}}, + })) + }) + mux.HandleFunc("/1/applications", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusNotFound) + return + } + srv.createCalls++ + require.NoError(t, json.NewEncoder(w).Encode(appResponse)) + }) + mux.HandleFunc( + "/1/applications/APP1/api-keys", + func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(dashboard.CreateAPIKeyResponse{ + Data: dashboard.APIKeyResource{ + ID: "key", + Type: "key", + Attributes: dashboard.APIKeyAttributes{Value: "test-api-key"}, + }, + })) + }, + ) + mux.HandleFunc( + "/1/applications/APP1/plan/self-serve", + func(w http.ResponseWriter, r *http.Request) { + srv.patchCalls++ + if srv.failPatch { + w.WriteHeader(http.StatusInternalServerError) + return + } + var payload dashboard.ChangePlanRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + srv.lastPlan = payload.Plan + require.NoError(t, json.NewEncoder(w).Encode(appResponse)) + }, + ) + + srv.Server = httptest.NewServer(mux) + return srv +} + +func newPrintFlags(output string) *cmdutil.PrintFlags { + pf := cmdutil.NewPrintFlags() + *pf.OutputFormat = output + pf.OutputFlagSpecified = func() bool { return output != "" } + return pf +} + +// newOpts builds CreateOptions wired to the stub server, defaulting to JSON output. +func newOpts( + t *testing.T, + srv *createServer, + isTTY bool, +) (*CreateOptions, *test.CmdInOut, *string) { + t.Helper() + seedToken(t) + + f, out := test.NewFactory(isTTY, nil, nil, "") + opened := new(string) + opts := &CreateOptions{ + IO: f.IOStreams, + Config: f.Config, + Name: "My First Application", + Region: "CA", + nameProvided: true, + PrintFlags: newPrintFlags("json"), + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + c.DashboardURL = "https://dashboard.algolia.com" + return c + }, + Browser: func(url string) error { + *opened = url + return nil + }, + } + return opts, out, opened +} + +func TestRun_FreeNonInteractive(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.AcceptTerms = true + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 1, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "APP1") +} + +func TestRun_NonInteractiveRequiresAcceptTerms(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + opts, _, _ := newOpts(t, srv, false) + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be accepted") + assert.Equal(t, 0, srv.createCalls) +} + +func TestRun_PaidWithBillingNonInteractive(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.AcceptTerms = true + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 1, srv.createCalls) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "grow", srv.lastPlan) + assert.Contains(t, out.String(), "APP1") +} + +func TestRun_PaidWithBillingRequiresAcceptTerms(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + opts, _, _ := newOpts(t, srv, false) + opts.Plan = "grow" + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be accepted") + assert.Equal(t, 0, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) +} + +func TestRun_PaidNoBillingNonInteractive(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.AcceptTerms = true + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "payment method") + assert.Equal(t, 0, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "https://dashboard.algolia.com/account/billing/details") +} + +func TestRun_PaidNoBillingInteractiveOpensBilling(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + defer srv.Close() + + defer prompt.StubConfirm(true)() + + opts, _, opened := newOpts(t, srv, true) + opts.Plan = "grow" + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 0, srv.createCalls) + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details", + *opened, + ) +} + +func TestRun_PaidNoBillingInteractiveDeclineOpen(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + defer srv.Close() + + defer prompt.StubConfirm(false)() + + opts, _, opened := newOpts(t, srv, true) + opts.Plan = "grow" + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 0, srv.createCalls) + assert.Empty(t, *opened) +} + +func TestRun_ToSDeclineAborts(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + defer prompt.StubConfirm(false)() + + opts, out, _ := newOpts(t, srv, true) + opts.Plan = "free" + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 0, srv.createCalls) + assert.Contains(t, out.String(), "Aborted") +} + +func TestRun_AcceptTermsSkipsPromptInteractive(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + // Confirm stubbed to NO; --accept-terms must bypass the prompt. + defer prompt.StubConfirm(false)() + + opts, out, _ := newOpts(t, srv, true) + opts.Plan = "free" + opts.AcceptTerms = true + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 1, srv.createCalls) + assert.Contains(t, out.String(), "Terms accepted via --accept-terms") +} + +func TestRun_PaidPlanHiddenByServerNonInteractive(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + srv.freeOnly = true + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.AcceptTerms = true + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "payment method") + assert.NotContains(t, err.Error(), "invalid plan") + assert.Equal(t, 0, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "https://dashboard.algolia.com/account/billing/details") + assert.Contains(t, out.String(), "--plan grow") +} + +func TestRun_PaidPlanHiddenByServerInteractiveOpensBilling(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + srv.freeOnly = true + defer srv.Close() + + defer prompt.StubConfirm(true)() + + opts, _, opened := newOpts(t, srv, true) + opts.Plan = "grow" + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 0, srv.createCalls) + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details", + *opened, + ) +} + +func TestRun_InvalidPlanErrors(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + opts, _, _ := newOpts(t, srv, false) + opts.Plan = "bogus" + opts.AcceptTerms = true + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid plan") + assert.Equal(t, 0, srv.createCalls) +} + +func TestRun_InteractivePickerHidesPaidWithoutBilling(t *testing.T) { + srv := newServer(t, `{"has_payment_method": false}`) + defer srv.Close() + + defer prompt.StubConfirm(true)() + + opts, out, _ := newOpts(t, srv, true) + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 1, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "only the Free plan is available") + assert.Contains(t, out.String(), "APP1") +} + +func TestRun_InteractivePickerSelectsPaid(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + origAsk := prompt.SurveyAskOne + prompt.SurveyAskOne = func(_ survey.Prompt, response interface{}, _ ...survey.AskOpt) error { + *(response.(*int)) = 1 + return nil + } + t.Cleanup(func() { prompt.SurveyAskOne = origAsk }) + defer prompt.StubConfirm(true)() + + opts, out, _ := newOpts(t, srv, true) + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 1, srv.createCalls) + assert.Equal(t, 1, srv.patchCalls) + assert.Equal(t, "grow", srv.lastPlan) + assert.Contains(t, out.String(), "APP1") +} + +func TestRun_DryRunDoesNotCallAPI(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + defer srv.Close() + + opts, out, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.DryRun = true + opts.PrintFlags = newPrintFlags("") + + require.NoError(t, runCreateCmd(opts)) + assert.Equal(t, 0, srv.createCalls) + assert.Equal(t, 0, srv.patchCalls) + assert.Contains(t, out.String(), "Dry run") + assert.Contains(t, out.String(), "grow") +} + +func TestRun_PlanChangeFailureKeepsFreeApp(t *testing.T) { + srv := newServer(t, `{"has_payment_method": true}`) + srv.failPatch = true + defer srv.Close() + + opts, _, _ := newOpts(t, srv, false) + opts.Plan = "grow" + opts.AcceptTerms = true + + err := runCreateCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to apply") + assert.Equal(t, 1, srv.createCalls) + assert.Equal(t, 1, srv.patchCalls) +} + +func TestResolveName(t *testing.T) { + t.Run("explicit flag wins", func(t *testing.T) { + f, _ := test.NewFactory(true, nil, nil, "") + opts := &CreateOptions{IO: f.IOStreams, Name: "Explicit", nameProvided: true} + name, err := resolveName(opts) + require.NoError(t, err) + assert.Equal(t, "Explicit", name) + }) + + t.Run("interactive prompt returns entered value", func(t *testing.T) { + f, _ := test.NewFactory(true, nil, nil, "") + origAsk := prompt.SurveyAskOne + prompt.SurveyAskOne = func(_ survey.Prompt, response interface{}, _ ...survey.AskOpt) error { + *(response.(*string)) = "Typed Name" + return nil + } + t.Cleanup(func() { prompt.SurveyAskOne = origAsk }) + + opts := &CreateOptions{IO: f.IOStreams, Name: "My First Application"} + name, err := resolveName(opts) + require.NoError(t, err) + assert.Equal(t, "Typed Name", name) + }) + + t.Run("empty interactive input falls back to default", func(t *testing.T) { + f, _ := test.NewFactory(true, nil, nil, "") + origAsk := prompt.SurveyAskOne + prompt.SurveyAskOne = func(_ survey.Prompt, response interface{}, _ ...survey.AskOpt) error { + *(response.(*string)) = "" + return nil + } + t.Cleanup(func() { prompt.SurveyAskOne = origAsk }) + + opts := &CreateOptions{IO: f.IOStreams, Name: "My First Application"} + name, err := resolveName(opts) + require.NoError(t, err) + assert.Equal(t, "My First Application", name) + }) + + t.Run("non-interactive falls back to default", func(t *testing.T) { + f, _ := test.NewFactory(false, nil, nil, "") + opts := &CreateOptions{IO: f.IOStreams, Name: "My First Application"} + name, err := resolveName(opts) + require.NoError(t, err) + assert.Equal(t, "My First Application", name) + }) +} diff --git a/pkg/cmd/application/planchange/planchange.go b/pkg/cmd/application/planchange/planchange.go index c99d6f77..34862144 100644 --- a/pkg/cmd/application/planchange/planchange.go +++ b/pkg/cmd/application/planchange/planchange.go @@ -286,9 +286,7 @@ func pickPlan(candidates []dashboard.Plan) (*dashboard.Plan, error) { return &candidates[selected], nil } -// confirmToS displays the plan's terms and asks the user to accept them. The -// prompt defaults to yes ([Y/n]). In non-interactive mode acceptance requires -// the --accept-terms flag (chosen over silent auto-accept). +// confirmToS shows the plan's terms and returns whether they were accepted. func confirmToS(opts *Options, target dashboard.Plan) (bool, error) { cs := opts.IO.ColorScheme() @@ -298,11 +296,12 @@ func confirmToS(opts *Options, target dashboard.Plan) (bool, error) { } fmt.Fprintf(opts.IO.Out, "\n%s\n\n", terms) + if opts.AcceptTerms { + fmt.Fprintf(opts.IO.Out, "%s Terms accepted via --accept-terms.\n", cs.SuccessIcon()) + return true, nil + } + if !opts.IO.CanPrompt() { - if opts.AcceptTerms { - fmt.Fprintf(opts.IO.Out, "%s Terms accepted via --accept-terms.\n", cs.SuccessIcon()) - return true, nil - } return false, cmdutil.FlagErrorf( "the plan terms must be accepted in non-interactive mode; pass --accept-terms to confirm", ) diff --git a/pkg/cmd/shared/apputil/plan.go b/pkg/cmd/shared/apputil/plan.go new file mode 100644 index 00000000..a8ac6d83 --- /dev/null +++ b/pkg/cmd/shared/apputil/plan.go @@ -0,0 +1,124 @@ +package apputil + +import ( + "fmt" + "strings" + + "github.com/AlecAivazis/survey/v2" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/prompt" +) + +// ResolvePlan maps a --plan value to one of the available plans. An exact match +// on the plan id wins; the user-facing "free" choice maps to the free-type +// template, whose id is not fixed (it can be "build"), so it is matched on type. +func ResolvePlan(plans []dashboard.Plan, value string) (*dashboard.Plan, error) { + for i := range plans { + if plans[i].ID == value { + return &plans[i], nil + } + } + if value == dashboard.PlanTypeFree { + if free := FindFreePlan(plans); free != nil { + return free, nil + } + } + return nil, cmdutil.FlagErrorf( + "invalid plan %q; valid plans: %s", + value, + strings.Join(PlanChoices(plans), ", "), + ) +} + +var knownPaidPlanNames = map[string]string{ + "grow": "Grow", + "grow-plus": "Grow Plus", +} + +// KnownPaidPlan recognizes a documented paid --plan value even when the +// self-serve endpoint omits it (paid plans appear only once billing is on file). +func KnownPaidPlan(value string) *dashboard.Plan { + name, ok := knownPaidPlanNames[value] + if !ok { + return nil + } + return &dashboard.Plan{ + ID: value, + Name: name, + Type: "freeform", + } +} + +// PlanAvailable reports whether a plan with the given id is in the list. +func PlanAvailable(plans []dashboard.Plan, id string) bool { + for i := range plans { + if plans[i].ID == id { + return true + } + } + return false +} + +// PlanChoices returns the user-facing plan identifiers (the free plan is shown +// as "free" regardless of its underlying id). +func PlanChoices(plans []dashboard.Plan) []string { + choices := make([]string, 0, len(plans)) + for _, p := range plans { + if p.IsFree() { + choices = append(choices, dashboard.PlanTypeFree) + } else { + choices = append(choices, p.ID) + } + } + return choices +} + +// SelectablePlans returns the plans a user may choose from. When hideNonFree is +// true (no payment method on file) only the free plan(s) are offered, because +// paid plans require billing details the CLI can't collect. +func SelectablePlans(plans []dashboard.Plan, hideNonFree bool) []dashboard.Plan { + if !hideNonFree { + return plans + } + free := make([]dashboard.Plan, 0, 1) + for _, p := range plans { + if p.IsFree() { + free = append(free, p) + } + } + return free +} + +// FindFreePlan returns the free-tier plan, or nil if none is present. +func FindFreePlan(plans []dashboard.Plan) *dashboard.Plan { + for i := range plans { + if plans[i].IsFree() { + return &plans[i] + } + } + return nil +} + +// PickPlan shows an interactive selector over the candidate plans. +func PickPlan(candidates []dashboard.Plan) (*dashboard.Plan, error) { + if len(candidates) == 0 { + return nil, fmt.Errorf("no plans are available") + } + labels := make([]string, len(candidates)) + for i, p := range candidates { + labels[i] = fmt.Sprintf("%s — %s", p.Name, p.Price) + } + var selected int + if err := prompt.SurveyAskOne( + &survey.Select{ + Message: "Select a plan:", + Options: labels, + }, + &selected, + ); err != nil { + return nil, err + } + return &candidates[selected], nil +} From f3075a54c605f740125fdbce52a07558b887d18a Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Tue, 2 Jun 2026 11:41:47 -0700 Subject: [PATCH 2/2] fix: wording --- pkg/cmd/application/planchange/planchange.go | 2 +- pkg/cmd/shared/apputil/plan.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cmd/application/planchange/planchange.go b/pkg/cmd/application/planchange/planchange.go index 34862144..339897c0 100644 --- a/pkg/cmd/application/planchange/planchange.go +++ b/pkg/cmd/application/planchange/planchange.go @@ -258,7 +258,7 @@ func resolvePlan(plans []dashboard.Plan, value string) (*dashboard.Plan, error) } } return nil, cmdutil.FlagErrorf( - "Invalid plan %q; valid plans: %s", + "Invalid plan %q; available plans: %s", value, strings.Join(planChoices(plans), ", "), ) diff --git a/pkg/cmd/shared/apputil/plan.go b/pkg/cmd/shared/apputil/plan.go index a8ac6d83..4c514156 100644 --- a/pkg/cmd/shared/apputil/plan.go +++ b/pkg/cmd/shared/apputil/plan.go @@ -26,7 +26,7 @@ func ResolvePlan(plans []dashboard.Plan, value string) (*dashboard.Plan, error) } } return nil, cmdutil.FlagErrorf( - "invalid plan %q; valid plans: %s", + "invalid plan %q; available plans: %s", value, strings.Join(PlanChoices(plans), ", "), )