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
8 changes: 6 additions & 2 deletions pkg/auth/authenticate.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ func EnsureAuthenticated(
cs := io.ColorScheme()
fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err)

return RunOAuth(io, client, false, true)
// No flow tracker: this re-authentication belongs to the calling flow,
// not to an `auth login` funnel.
return RunOAuth(io, client, false, true, nil)
}

// ReauthenticateIfExpired checks if err is a session-expired error from the API.
Expand All @@ -41,5 +43,7 @@ func ReauthenticateIfExpired(
ClearToken()
fmt.Fprintf(io.Out, "%s Session expired.\n", cs.WarningIcon())

return RunOAuth(io, client, false, true)
// No flow tracker: this re-authentication belongs to the calling flow,
// not to an `auth login` funnel.
return RunOAuth(io, client, false, true, nil)
}
13 changes: 12 additions & 1 deletion pkg/auth/oauth_flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (

"github.com/algolia/cli/api/dashboard"
"github.com/algolia/cli/pkg/iostreams"
"github.com/algolia/cli/pkg/telemetry"
)

// DefaultOAuthClientID is a public OAuth client ID (PKCE flow, not a secret).
Expand Down Expand Up @@ -35,7 +36,15 @@ func OAuthClientID() string {
// launched, e.g. SSH / containers).
//
// If signup is true the browser opens to the sign-up page.
func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBrowser bool) (string, error) {
//
// The optional tracker (nil-safe) records which step the flow is in, so the
// telemetry of the calling flow can tell where the user stopped.
func RunOAuth(
io *iostreams.IOStreams,
client *dashboard.Client,
signup, openBrowser bool,
tracker *telemetry.FlowTracker,
) (string, error) {
cs := io.ColorScheme()

redirectURI, resultCh, err := StartCallbackServer()
Expand Down Expand Up @@ -69,6 +78,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro
}

fmt.Fprintf(io.Out, "Waiting for authentication...\n")
tracker.SetStep(telemetry.StepBrowserWait)
cbResult := <-resultCh

if cbResult.Error != "" {
Expand All @@ -78,6 +88,7 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro
return "", fmt.Errorf("no authorization code received")
}

tracker.SetStep(telemetry.StepCodeExchange)
io.StartProgressIndicatorWithLabel("Exchanging code for tokens")
tokenResp, err := client.AuthorizationCodeGrant(cbResult.Code, codeVerifier, redirectURI)
io.StopProgressIndicator()
Expand Down
161 changes: 121 additions & 40 deletions pkg/cmd/application/create/create.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package create

import (
"context"
"fmt"
"strings"

Expand All @@ -16,6 +17,7 @@ import (
"github.com/algolia/cli/pkg/iostreams"
pkgopen "github.com/algolia/cli/pkg/open"
"github.com/algolia/cli/pkg/prompt"
"github.com/algolia/cli/pkg/telemetry"
"github.com/algolia/cli/pkg/validators"
)

Expand Down Expand Up @@ -78,7 +80,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.nameProvided = cmd.Flags().Changed("name")
return runCreateCmd(opts)
return runCreateCmd(cmd.Context(), opts)
},
}

Expand All @@ -99,57 +101,119 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
return cmd
}

func runCreateCmd(opts *CreateOptions) error {
cs := opts.IO.ColorScheme()
func runCreateCmd(ctx context.Context, opts *CreateOptions) error {
if opts.DryRun {
return printDryRunSummary(opts)
}

tracker := telemetry.NewFlowTracker()
telemetry.TrackEvent(ctx, telemetry.ApplicationCreateStarted())

result, err := createApplication(ctx, opts, tracker)
trackCreateOutcome(ctx, tracker, result, err)
return err
}

// trackCreateOutcome reports how the creation flow ended: completed, aborted
// (with the reason why), or failed.
func trackCreateOutcome(
ctx context.Context,
tracker *telemetry.FlowTracker,
result createResult,
err error,
) {
switch {
case err == nil && result.created:
telemetry.TrackEvent(
ctx,
telemetry.ApplicationCreateCompleted(result.region, result.plan, tracker),
)
case err == nil || result.abortReason != "" || cmdutil.IsUserCancellation(err):
// Stopped without creating anything: declined terms, billing wall,
// or user cancellation.
reason := result.abortReason
if reason == "" && cmdutil.IsUserCancellation(err) {
reason = telemetry.AbortReasonCancelled
}
telemetry.TrackEvent(ctx, telemetry.ApplicationCreateAborted(tracker, reason))
default:
telemetry.TrackEvent(ctx, telemetry.ApplicationCreateFailed(tracker, err))
}
}

// printDryRunSummary prints what would be created without sending anything.
func printDryRunSummary(opts *CreateOptions) error {
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": name,
"region": opts.Region,
"plan": planLabel,
"default": opts.Default,
"dryRun": true,
}
return cmdutil.PrintRunSummary(
opts.IO,
opts.PrintFlags,
summary,
fmt.Sprintf(
"Dry run: would create application %q in region %q on the %q plan",
name,
opts.Region,
planLabel,
),
)
planLabel := opts.Plan
if planLabel == "" {
planLabel = dashboard.PlanTypeFree
}
summary := map[string]any{
"action": "create_application",
"name": name,
"region": opts.Region,
"plan": planLabel,
"default": opts.Default,
"dryRun": true,
}
return cmdutil.PrintRunSummary(
opts.IO,
opts.PrintFlags,
summary,
fmt.Sprintf(
"Dry run: would create application %q in region %q on the %q plan",
name,
opts.Region,
planLabel,
),
)
}

// createResult carries what the creation flow produced, for telemetry.
type createResult struct {
created bool
region string
plan string
abortReason telemetry.AbortReason
}

func createApplication(
ctx context.Context,
opts *CreateOptions,
tracker *telemetry.FlowTracker,
) (createResult, error) {
var result createResult
cs := opts.IO.ColorScheme()

tracker.SetStep(telemetry.StepName)
name, err := resolveName(opts)
if err != nil {
return result, err
}

client := opts.NewDashboardClient(auth.OAuthClientID())

tracker.SetStep(telemetry.StepAuth)
token, err := auth.EnsureAuthenticated(opts.IO, client)
if err != nil {
return err
return result, err
}

tracker.SetStep(telemetry.StepPlan)
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
return result, err
}
if len(plans) == 0 {
return fmt.Errorf("no self-serve plans are available")
return result, fmt.Errorf("no self-serve plans are available")
}

// Best-effort: continue without billing status if /1/user fails.
Expand All @@ -164,32 +228,48 @@ func runCreateCmd(opts *CreateOptions) error {

target, err := selectPlan(opts, plans, user)
if err != nil {
return err
return result, err
}
result.plan = apputil.PlanTelemetryID(*target)

if !target.IsFree() {
billingMissing := !apputil.PlanAvailable(plans, target.ID) ||
(user != nil && !user.HasPaymentMethod)
if billingMissing {
return offerBilling(opts, client, *target)
result.abortReason = telemetry.AbortReasonBillingRequired
return result, offerBilling(opts, client, *target)
}
}

tracker.SetStep(telemetry.StepTerms)
accepted, err := confirmToS(opts, *target)
if err != nil {
return err
return result, err
}
if !accepted {
telemetry.TrackEvent(ctx, telemetry.ApplicationCreateDeclinedTerms(result.plan))
fmt.Fprintf(opts.IO.Out, "%s Aborted; no application was created.\n", cs.WarningIcon())
return nil
result.abortReason = telemetry.AbortReasonDeclinedTerms
return result, nil
}
telemetry.TrackEvent(ctx, telemetry.ApplicationCreateAcceptedTerms(result.plan))

appDetails, err := apputil.CreateAndFetchApplication(opts.IO, client, token, opts.Region, name)
appDetails, createdRegion, err := apputil.CreateAndFetchApplication(
opts.IO,
client,
token,
opts.Region,
name,
tracker,
)
if err != nil {
return err
return result, err
}
result.created = true
result.region = createdRegion

if !target.IsFree() {
tracker.SetStep(telemetry.StepApplyPlan)
if err := callWithReauth(opts.IO, client, &token, "Applying plan", func(t string) error {
_, e := client.ChangeApplicationPlan(t, appDetails.ID, target.ID)
return e
Expand All @@ -216,7 +296,7 @@ func runCreateCmd(opts *CreateOptions) error {
opts.Default,
)
}
return fmt.Errorf(
return result, fmt.Errorf(
"failed to apply the %q plan to application %s: %w",
target.Name,
appDetails.ID,
Expand All @@ -235,12 +315,13 @@ func runCreateCmd(opts *CreateOptions) error {
if opts.structuredOutput() {
p, err := opts.PrintFlags.ToPrinter()
if err != nil {
return err
return result, err
}
return p.Print(opts.IO, appDetails)
return result, p.Print(opts.IO, appDetails)
}

return apputil.ConfigureProfile(
tracker.SetStep(telemetry.StepProfileConfigure)
return result, apputil.ConfigureProfile(
opts.IO,
opts.Config,
appDetails,
Expand Down
Loading
Loading