Skip to content
Draft
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
21 changes: 15 additions & 6 deletions api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,14 @@ func (c *Client) CreateApplication(accessToken, region, name string) (*Applicati
}
}

return nil, fmt.Errorf(
"create application failed with status %d: %s",
resp.StatusCode,
respStr,
)
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf(
"create application failed with status %d: %s",
resp.StatusCode,
respStr,
),
}
}

var singleResp SingleApplicationResponse
Expand Down Expand Up @@ -493,7 +496,13 @@ func (c *Client) ChangeApplicationPlan(accessToken, appID, plan string) (*Applic
return nil, ErrSessionExpired
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Couldn't change your application's plan: %d", resp.StatusCode)
return nil, &APIError{
StatusCode: resp.StatusCode,
Message: fmt.Sprintf(
"Couldn't change your application's plan: %d",
resp.StatusCode,
),
}
}

respBody, _ := io.ReadAll(resp.Body)
Expand Down
11 changes: 11 additions & 0 deletions api/dashboard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,17 @@ type RegionsResponse struct {
// ErrSessionExpired is returned when an API call gets a 401 Unauthorized.
var ErrSessionExpired = errors.New("session expired")

// APIError is returned for non-2xx dashboard responses. It carries the HTTP
// status so callers (and telemetry) can branch on it, keeping the message.
type APIError struct {
StatusCode int
Message string
}

func (e *APIError) Error() string { return e.Message }

func (e *APIError) HTTPStatusCode() int { return e.StatusCode }

// ErrClusterUnavailable is returned when a region has no available cluster.
type ErrClusterUnavailable struct {
Region string
Expand Down
7 changes: 5 additions & 2 deletions pkg/auth/authenticate.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"context"
"errors"
"fmt"

Expand All @@ -23,7 +24,9 @@ func EnsureAuthenticated(
cs := io.ColorScheme()
fmt.Fprintf(io.Out, "%s %s\n", cs.WarningIcon(), err)

return RunOAuth(io, client, false, true)
// Lazy login from another command: no request-scoped telemetry context here,
// so OAuth flow events are emitted only by the dedicated auth login/signup commands.
return RunOAuth(context.Background(), io, client, false, true)
}

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

return RunOAuth(io, client, false, true)
return RunOAuth(context.Background(), io, client, false, true)
}
52 changes: 47 additions & 5 deletions pkg/auth/oauth_flow.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package auth

import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"time"

"github.com/algolia/cli/api/dashboard"
"github.com/algolia/cli/pkg/cmdutil"
"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 All @@ -20,7 +24,10 @@ func OAuthClientID() string {
return v
}
if DefaultOAuthClientID == "" {
fmt.Fprintln(os.Stderr, "fatal: ALGOLIA_OAUTH_CLIENT_ID is not set and no default was compiled in")
fmt.Fprintln(
os.Stderr,
"fatal: ALGOLIA_OAUTH_CLIENT_ID is not set and no default was compiled in",
)
os.Exit(1)
}
return DefaultOAuthClientID
Expand All @@ -35,11 +42,27 @@ 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) {
func RunOAuth(
ctx context.Context,
io *iostreams.IOStreams,
client *dashboard.Client,
signup, openBrowser bool,
) (string, error) {
cs := io.ColorScheme()

flow := telemetry.FlowLogin
if signup {
flow = telemetry.FlowSignup
}
start := time.Now()
telemetry.Track(ctx, telemetry.AuthStarted(flow, !openBrowser))

redirectURI, resultCh, err := StartCallbackServer()
if err != nil {
telemetry.Track(
ctx,
telemetry.AuthFailed(flow, telemetry.AuthStepCallback, cmdutil.ErrorClass(err)),
)
return "", err
}

Expand All @@ -63,25 +86,44 @@ func RunOAuth(io *iostreams.IOStreams, client *dashboard.Client, signup, openBro
fmt.Fprintf(io.Out, "Opening browser to sign in...\n")
}
fmt.Fprintf(io.Out, "If the browser doesn't open, visit:\n %s\n\n", cs.Bold(authorizeURL))
_ = OpenBrowser(authorizeURL)
if browserErr := OpenBrowser(authorizeURL); browserErr != nil {
telemetry.Track(ctx, telemetry.AuthBrowserFailed(flow, cmdutil.ErrorClass(browserErr)))
} else {
telemetry.Track(ctx, telemetry.AuthBrowserOpened(flow))
}
} else {
fmt.Fprintf(io.Out, "Open this URL in your browser to authenticate:\n\n %s\n\n", cs.Bold(authorizeURL))
}

fmt.Fprintf(io.Out, "Waiting for authentication...\n")
cbResult := <-resultCh
telemetry.Track(ctx, telemetry.AuthCallbackReceived(flow, time.Since(start)))

if cbResult.Error != "" {
return "", fmt.Errorf("authorization failed: %s", cbResult.Error)
err := fmt.Errorf("authorization failed: %s", cbResult.Error)
telemetry.Track(
ctx,
telemetry.AuthFailed(flow, telemetry.AuthStepCallback, cmdutil.ErrorClass(err)),
)
return "", err
}
if cbResult.Code == "" {
return "", fmt.Errorf("no authorization code received")
err := fmt.Errorf("no authorization code received")
telemetry.Track(
ctx,
telemetry.AuthFailed(flow, telemetry.AuthStepCallback, cmdutil.ErrorClass(err)),
)
return "", err
}

io.StartProgressIndicatorWithLabel("Exchanging code for tokens")
tokenResp, err := client.AuthorizationCodeGrant(cbResult.Code, codeVerifier, redirectURI)
io.StopProgressIndicator()
if err != nil {
telemetry.Track(
ctx,
telemetry.AuthFailed(flow, telemetry.AuthStepExchange, cmdutil.ErrorClass(err)),
)
return "", err
}

Expand Down
20 changes: 17 additions & 3 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,7 +101,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
return cmd
}

func runCreateCmd(opts *CreateOptions) error {
func runCreateCmd(ctx context.Context, opts *CreateOptions) error {
cs := opts.IO.ColorScheme()

name, err := resolveName(opts)
Expand Down Expand Up @@ -180,11 +182,23 @@ func runCreateCmd(opts *CreateOptions) error {
return err
}
if !accepted {
telemetry.Track(
ctx,
telemetry.ApplicationCreateAborted(telemetry.TriggeredFromExplicitCommand),
)
fmt.Fprintf(opts.IO.Out, "%s Aborted; no application was created.\n", cs.WarningIcon())
return nil
}

appDetails, err := apputil.CreateAndFetchApplication(opts.IO, client, token, opts.Region, name)
appDetails, err := apputil.CreateAndFetchApplication(
ctx,
opts.IO,
client,
token,
opts.Region,
name,
telemetry.TriggeredFromExplicitCommand,
)
if err != nil {
return err
}
Expand Down
33 changes: 17 additions & 16 deletions pkg/cmd/application/create/create_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package create

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -212,7 +213,7 @@ func TestRun_FreeNonInteractive(t *testing.T) {
opts, out, _ := newOpts(t, srv, false)
opts.AcceptTerms = true

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 1, srv.createCalls)
assert.Equal(t, 0, srv.patchCalls)
assert.Contains(t, out.String(), "APP1")
Expand All @@ -224,7 +225,7 @@ func TestRun_NonInteractiveRequiresAcceptTerms(t *testing.T) {

opts, _, _ := newOpts(t, srv, false)

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "must be accepted")
assert.Equal(t, 0, srv.createCalls)
Expand All @@ -238,7 +239,7 @@ func TestRun_PaidWithBillingNonInteractive(t *testing.T) {
opts.Plan = "grow"
opts.AcceptTerms = true

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 1, srv.createCalls)
assert.Equal(t, 1, srv.patchCalls)
assert.Equal(t, "grow", srv.lastPlan)
Expand All @@ -252,7 +253,7 @@ func TestRun_PaidWithBillingRequiresAcceptTerms(t *testing.T) {
opts, _, _ := newOpts(t, srv, false)
opts.Plan = "grow"

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "must be accepted")
assert.Equal(t, 0, srv.createCalls)
Expand All @@ -267,7 +268,7 @@ func TestRun_PaidNoBillingNonInteractive(t *testing.T) {
opts.Plan = "grow"
opts.AcceptTerms = true

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "payment method")
assert.Equal(t, 0, srv.createCalls)
Expand All @@ -284,7 +285,7 @@ func TestRun_PaidNoBillingInteractiveOpensBilling(t *testing.T) {
opts, _, opened := newOpts(t, srv, true)
opts.Plan = "grow"

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 0, srv.createCalls)
assert.Equal(
t,
Expand All @@ -302,7 +303,7 @@ func TestRun_PaidNoBillingInteractiveDeclineOpen(t *testing.T) {
opts, _, opened := newOpts(t, srv, true)
opts.Plan = "grow"

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 0, srv.createCalls)
assert.Empty(t, *opened)
}
Expand All @@ -316,7 +317,7 @@ func TestRun_ToSDeclineAborts(t *testing.T) {
opts, out, _ := newOpts(t, srv, true)
opts.Plan = "free"

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 0, srv.createCalls)
assert.Contains(t, out.String(), "Aborted")
}
Expand All @@ -332,7 +333,7 @@ func TestRun_AcceptTermsSkipsPromptInteractive(t *testing.T) {
opts.Plan = "free"
opts.AcceptTerms = true

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 1, srv.createCalls)
assert.Contains(t, out.String(), "Terms accepted via --accept-terms")
}
Expand All @@ -346,7 +347,7 @@ func TestRun_PaidPlanHiddenByServerNonInteractive(t *testing.T) {
opts.Plan = "grow"
opts.AcceptTerms = true

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "payment method")
assert.NotContains(t, err.Error(), "invalid plan")
Expand All @@ -366,7 +367,7 @@ func TestRun_PaidPlanHiddenByServerInteractiveOpensBilling(t *testing.T) {
opts, _, opened := newOpts(t, srv, true)
opts.Plan = "grow"

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 0, srv.createCalls)
assert.Equal(
t,
Expand All @@ -383,7 +384,7 @@ func TestRun_InvalidPlanErrors(t *testing.T) {
opts.Plan = "bogus"
opts.AcceptTerms = true

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid plan")
assert.Equal(t, 0, srv.createCalls)
Expand All @@ -397,7 +398,7 @@ func TestRun_InteractivePickerHidesPaidWithoutBilling(t *testing.T) {

opts, out, _ := newOpts(t, srv, true)

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 1, srv.createCalls)
assert.Equal(t, 0, srv.patchCalls)
assert.Contains(t, out.String(), "only the Free plan is available")
Expand All @@ -418,7 +419,7 @@ func TestRun_InteractivePickerSelectsPaid(t *testing.T) {

opts, out, _ := newOpts(t, srv, true)

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 1, srv.createCalls)
assert.Equal(t, 1, srv.patchCalls)
assert.Equal(t, "grow", srv.lastPlan)
Expand All @@ -434,7 +435,7 @@ func TestRun_DryRunDoesNotCallAPI(t *testing.T) {
opts.DryRun = true
opts.PrintFlags = newPrintFlags("")

require.NoError(t, runCreateCmd(opts))
require.NoError(t, runCreateCmd(context.Background(), opts))
assert.Equal(t, 0, srv.createCalls)
assert.Equal(t, 0, srv.patchCalls)
assert.Contains(t, out.String(), "Dry run")
Expand All @@ -450,7 +451,7 @@ func TestRun_PlanChangeFailureKeepsFreeApp(t *testing.T) {
opts.Plan = "grow"
opts.AcceptTerms = true

err := runCreateCmd(opts)
err := runCreateCmd(context.Background(), opts)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to apply")
assert.Equal(t, 1, srv.createCalls)
Expand Down
Loading
Loading