diff --git a/pkg/cmd/application/application.go b/pkg/cmd/application/application.go index 25ec5121..f9c5a695 100644 --- a/pkg/cmd/application/application.go +++ b/pkg/cmd/application/application.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" "github.com/algolia/cli/pkg/cmd/application/create" + "github.com/algolia/cli/pkg/cmd/application/current" "github.com/algolia/cli/pkg/cmd/application/downgrade" "github.com/algolia/cli/pkg/cmd/application/list" "github.com/algolia/cli/pkg/cmd/application/plans" @@ -23,6 +24,7 @@ func NewApplicationCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(create.NewCreateCmd(f)) cmd.AddCommand(list.NewListCmd(f)) + cmd.AddCommand(current.NewCurrentCmd(f)) cmd.AddCommand(selectapp.NewSelectCmd(f)) cmd.AddCommand(update.NewUpdateCmd(f)) cmd.AddCommand(plans.NewPlansCmd(f)) diff --git a/pkg/cmd/application/current/current.go b/pkg/cmd/application/current/current.go new file mode 100644 index 00000000..48001d52 --- /dev/null +++ b/pkg/cmd/application/current/current.go @@ -0,0 +1,149 @@ +package current + +import ( + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/auth" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/validators" +) + +type CurrentOptions struct { + IO *iostreams.IOStreams + Config config.IConfig + + PrintFlags *cmdutil.PrintFlags + + NewDashboardClient func(clientID string) *dashboard.Client +} + +type currentApplication struct { + ID string `json:"id"` + Alias string `json:"alias"` + Name string `json:"name"` + Plan string `json:"plan"` +} + +func NewCurrentCmd(f *cmdutil.Factory) *cobra.Command { + opts := &CurrentOptions{ + IO: f.IOStreams, + Config: f.Config, + PrintFlags: cmdutil.NewPrintFlags(), + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + } + + cmd := &cobra.Command{ + Use: "current", + Short: "Show the currently selected application", + Long: heredoc.Doc(` + Show which Algolia application is currently selected, along with its + name and plan. + + The application ID (and its alias, when set) is shown even if the name + and plan can't be fetched. + `), + Example: heredoc.Doc(` + # Show the current application + $ algolia application current + + # Output as JSON + $ algolia application current --output json + `), + Args: validators.NoArgs(), + Annotations: map[string]string{ + "skipAuthCheck": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runCurrentCmd(opts) + }, + } + + opts.PrintFlags.AddFlags(cmd) + + return cmd +} + +func runCurrentCmd(opts *CurrentOptions) error { + cs := opts.IO.ColorScheme() + + appID, err := opts.Config.Profile().GetApplicationID() + if err != nil { + return fmt.Errorf( + "no current application configured; run \"algolia application select\" or \"algolia auth login\" first: %w", + err, + ) + } + + current := currentApplication{ID: appID} + if alias, ok := opts.Config.ApplicationAlias(appID); ok { + current.Alias = alias + } + + app, signedOut := fetchApplication(opts, appID) + if app != nil { + current.Name = app.Name + current.Plan = app.PlanLabel + } + + if opts.PrintFlags.OutputFlagSpecified() && opts.PrintFlags.OutputFormat != nil { + p, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + return p.Print(opts.IO, current) + } + + fmt.Fprintf(opts.IO.Out, "%s Current application: %s\n", cs.SuccessIcon(), cs.Bold(appID)) + if current.Alias != "" { + fmt.Fprintf(opts.IO.Out, " Alias: %s\n", current.Alias) + } + if current.Name != "" { + fmt.Fprintf(opts.IO.Out, " Name: %s\n", current.Name) + } + if current.Plan != "" { + fmt.Fprintf(opts.IO.Out, " Plan: %s\n", current.Plan) + } + if current.Name == "" && current.Plan == "" { + if signedOut { + fmt.Fprintf( + opts.IO.Out, + "%s Sign in with \"algolia auth login\" to see the application name and plan.\n", + cs.WarningIcon(), + ) + } else { + fmt.Fprintf( + opts.IO.Out, + "%s Couldn't fetch the application name and plan; showing the selected application only.\n", + cs.WarningIcon(), + ) + } + } + + return nil +} + +func fetchApplication(opts *CurrentOptions, appID string) (*dashboard.Application, bool) { + client := opts.NewDashboardClient(auth.OAuthClientID()) + + token, err := auth.GetValidToken(client) + if err != nil { + return nil, true + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching application") + app, err := client.GetApplication(token, appID) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, false + } + + return app, false +} diff --git a/pkg/cmd/application/current/current_test.go b/pkg/cmd/application/current/current_test.go new file mode 100644 index 00000000..8aace58f --- /dev/null +++ b/pkg/cmd/application/current/current_test.go @@ -0,0 +1,169 @@ +package current + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "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/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +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 newServer(t *testing.T, status int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/1/application/APP1", func(w http.ResponseWriter, _ *http.Request) { + if status != http.StatusOK { + w.WriteHeader(status) + return + } + require.NoError(t, json.NewEncoder(w).Encode(dashboard.SingleApplicationResponse{ + Data: dashboard.ApplicationResource{ + ID: "APP1", + Type: "application", + Attributes: dashboard.ApplicationAttributes{ + ApplicationID: "APP1", + Name: "My App", + Plan: dashboard.ApplicationPlan{Label: "Grow Plus"}, + }, + }, + })) + }) + return httptest.NewServer(mux) +} + +func newOpts( + t *testing.T, + srv *httptest.Server, + cfg *test.ConfigStub, + output string, + signedIn bool, +) (*CurrentOptions, *bytes.Buffer) { + t.Helper() + if signedIn { + seedToken(t) + } else { + keyring.MockInit() + auth.ClearToken() + } + + io, _, stdout, _ := iostreams.Test() + pf := cmdutil.NewPrintFlags() + *pf.OutputFormat = output + pf.OutputFlagSpecified = func() bool { return output != "" } + + opts := &CurrentOptions{ + IO: io, + Config: cfg, + PrintFlags: pf, + NewDashboardClient: func(string) *dashboard.Client { + c := dashboard.NewClientWithHTTPClient("test", srv.Client()) + c.APIURL = srv.URL + return c + }, + } + return opts, stdout +} + +func configWithApp(appID, alias string) *test.ConfigStub { + cfg := &test.ConfigStub{ + CurrentProfile: config.Profile{ApplicationID: appID}, + } + if alias != "" { + cfg.SavedApps = map[string]test.SavedApplication{ + appID: {Alias: alias}, + } + } + return cfg +} + +func Test_runCurrentCmd(t *testing.T) { + srv := newServer(t, http.StatusOK) + defer srv.Close() + + opts, out := newOpts(t, srv, configWithApp("APP1", "my-alias"), "", true) + require.NoError(t, runCurrentCmd(opts)) + + got := out.String() + assert.Contains(t, got, "APP1") + assert.Contains(t, got, "my-alias") + assert.Contains(t, got, "My App") + assert.Contains(t, got, "Grow Plus") +} + +func Test_runCurrentCmd_notConfigured(t *testing.T) { + srv := newServer(t, http.StatusOK) + defer srv.Close() + + opts, _ := newOpts(t, srv, configWithApp("", ""), "", true) + err := runCurrentCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no current application configured") +} + +func Test_runCurrentCmd_apiFailure(t *testing.T) { + srv := newServer(t, http.StatusInternalServerError) + defer srv.Close() + + opts, out := newOpts(t, srv, configWithApp("APP1", "my-alias"), "", true) + require.NoError(t, runCurrentCmd(opts)) + + got := out.String() + assert.Contains(t, got, "APP1") + assert.Contains(t, got, "my-alias") + assert.NotContains(t, got, "My App") + assert.Contains(t, got, "Couldn't fetch the application name and plan") +} + +func Test_runCurrentCmd_signedOut(t *testing.T) { + hit := false + srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + hit = true + })) + defer srv.Close() + + opts, out := newOpts(t, srv, configWithApp("APP1", "my-alias"), "", false) + require.NoError(t, runCurrentCmd(opts)) + + got := out.String() + assert.Contains(t, got, "APP1") + assert.Contains(t, got, "my-alias") + assert.NotContains(t, got, "My App") + assert.Contains(t, got, `Sign in with "algolia auth login"`) + assert.False(t, hit, "expected no API/login call when signed out") +} + +func Test_runCurrentCmd_outputJSON(t *testing.T) { + srv := newServer(t, http.StatusOK) + defer srv.Close() + + opts, out := newOpts(t, srv, configWithApp("APP1", "my-alias"), "json", true) + require.NoError(t, runCurrentCmd(opts)) + + got := out.String() + assert.Contains(t, got, `"id":"APP1"`) + assert.Contains(t, got, `"alias":"my-alias"`) + assert.Contains(t, got, `"name":"My App"`) + assert.Contains(t, got, `"plan":"Grow Plus"`) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 90e4aa63..1ffd0670 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -33,6 +33,7 @@ type IConfig interface { // New model (state.toml + OS keychain). ActiveApplicationID() string APIKeyUUID(appID string) (string, bool) + ApplicationAlias(appID string) (string, bool) ApplicationInState(appID string) bool ApplicationIDByAlias(alias string) (string, bool) SaveApplication(appID, alias, apiKeyUUID, apiKey string, setCurrent bool) error diff --git a/pkg/config/write.go b/pkg/config/write.go index f3bffe7c..698745e6 100644 --- a/pkg/config/write.go +++ b/pkg/config/write.go @@ -28,6 +28,14 @@ func (c *Config) ApplicationIDByAlias(alias string) (string, bool) { return c.loadState().ApplicationByAlias(alias) } +func (c *Config) ApplicationAlias(appID string) (string, bool) { + app, ok := c.loadState().Applications[appID] + if !ok || app.Alias == "" { + return "", false + } + return app.Alias, true +} + // SaveApplication persists an application's credentials in the new model. // The keychain is written first so a failure never leaves state.toml pointing // at a key that was not stored. Empty alias/apiKeyUUID preserve the values diff --git a/test/config.go b/test/config.go index 8d1a507f..8592d716 100644 --- a/test/config.go +++ b/test/config.go @@ -160,6 +160,14 @@ func (c *ConfigStub) APIKeyUUID(appID string) (string, bool) { return app.APIKeyUUID, true } +func (c *ConfigStub) ApplicationAlias(appID string) (string, bool) { + app, ok := c.SavedApps[appID] + if !ok || app.Alias == "" { + return "", false + } + return app.Alias, true +} + func (c *ConfigStub) ApplicationIDByAlias(alias string) (string, bool) { for appID, app := range c.SavedApps { if app.Alias == alias {