From ec1ca87b6590d1b398bffc78f8f2c04d40c76827 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Fri, 29 May 2026 15:34:02 -0700 Subject: [PATCH 1/3] feat(deeplink): add algolia deeplink command --- pkg/cmd/application/selectapp/select.go | 55 ++++-- pkg/cmd/deeplink/deeplink.go | 215 ++++++++++++++++++++++++ pkg/cmd/deeplink/deeplink_test.go | 173 +++++++++++++++++++ pkg/cmd/root/root.go | 2 + 4 files changed, 430 insertions(+), 15 deletions(-) create mode 100644 pkg/cmd/deeplink/deeplink.go create mode 100644 pkg/cmd/deeplink/deeplink_test.go diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index e8298620..7acce3b3 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -58,22 +58,40 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { "skipAuthCheck": "true", }, RunE: func(cmd *cobra.Command, args []string) error { - return runSelectCmd(opts) + _, err := runSelectCmd(opts) + return err }, } - cmd.Flags().StringVar(&opts.AppName, "app-name", "", "Select application by name (non-interactive)") + cmd.Flags(). + StringVar(&opts.AppName, "app-name", "", "Select application by name (non-interactive)") return cmd } -func runSelectCmd(opts *SelectOptions) error { +// Run executes the interactive application-selection flow and returns the +// chosen application. Other commands (e.g. deeplink) use it to ensure an +// application is selected before proceeding. A nil application is returned +// when the account has no applications. +func Run(f *cmdutil.Factory) (*dashboard.Application, error) { + opts := &SelectOptions{ + IO: f.IOStreams, + Config: f.Config, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + } + + return runSelectCmd(opts) +} + +func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) { cs := opts.IO.ColorScheme() client := opts.NewDashboardClient(auth.OAuthClientID()) accessToken, err := auth.EnsureAuthenticated(opts.IO, client) if err != nil { - return err + return nil, err } opts.IO.StartProgressIndicatorWithLabel("Fetching applications") @@ -82,26 +100,26 @@ func runSelectCmd(opts *SelectOptions) error { if err != nil { newToken, reAuthErr := auth.ReauthenticateIfExpired(opts.IO, client, err) if reAuthErr != nil { - return reAuthErr + return nil, reAuthErr } accessToken = newToken opts.IO.StartProgressIndicatorWithLabel("Fetching applications") apps, err = client.ListApplications(accessToken) opts.IO.StopProgressIndicator() if err != nil { - return err + return nil, err } } if len(apps) == 0 { fmt.Fprintf(opts.IO.Out, "%s No applications found.\n", cs.WarningIcon()) fmt.Fprintf(opts.IO.Out, " Use %s to create one.\n", cs.Bold("algolia application create")) - return nil + return nil, nil } chosen, err := pickApplication(opts, apps) if err != nil { - return err + return nil, err } // If a profile already exists for this app, switch the default @@ -119,7 +137,7 @@ func runSelectCmd(opts *SelectOptions) error { } if err := opts.Config.SetDefaultProfile(profileName); err != nil { - return fmt.Errorf("failed to set default profile: %w", err) + return nil, fmt.Errorf("failed to set default profile: %w", err) } fmt.Fprintf(opts.IO.Out, "%s Switched to profile %q (application %s).\n", cs.SuccessIcon(), profileName, cs.Bold(chosen.ID)) @@ -127,27 +145,34 @@ func runSelectCmd(opts *SelectOptions) error { if existingProfile != nil && existingProfile.APIKey == "" { app := &dashboard.Application{ID: chosen.ID, Name: chosen.Name} if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, app); err != nil { - return err + return nil, err } existingProfile.ApplicationID = chosen.ID existingProfile.APIKey = app.APIKey if err := existingProfile.Add(); err != nil { - return err + return nil, err } fmt.Fprintf(opts.IO.Out, "%s Profile %q updated with API key.\n", cs.SuccessIcon(), profileName) } - return nil + return chosen, nil } if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil { - return err + return nil, err + } + + if err := apputil.ConfigureProfile(opts.IO, opts.Config, chosen, "", true); err != nil { + return nil, err } - return apputil.ConfigureProfile(opts.IO, opts.Config, chosen, "", true) + return chosen, nil } -func pickApplication(opts *SelectOptions, apps []dashboard.Application) (*dashboard.Application, error) { +func pickApplication( + opts *SelectOptions, + apps []dashboard.Application, +) (*dashboard.Application, error) { if opts.AppName != "" { for i := range apps { if apps[i].Name == opts.AppName { diff --git a/pkg/cmd/deeplink/deeplink.go b/pkg/cmd/deeplink/deeplink.go new file mode 100644 index 00000000..273f9e4e --- /dev/null +++ b/pkg/cmd/deeplink/deeplink.go @@ -0,0 +1,215 @@ +package deeplink + +import ( + "fmt" + "net/url" + "strings" + + "github.com/AlecAivazis/survey/v2" + "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/cmd/application/selectapp" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/open" + "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/validators" +) + +// purposeTarget describes how to build the dashboard URL for a --purpose value. +type purposeTarget struct { + // path is the dashboard path for the destination. + path string + // accountScoped marks account-level pages. They live at + // {base}/{path}?applicationId={appID}, whereas application pages live at + // {base}/apps/{appID}/{path}. + accountScoped bool +} + +// purposeTargets maps each --purpose value to its dashboard destination. +var purposeTargets = map[string]purposeTarget{ + "dashboard": {path: "dashboard"}, + "indices": {path: "explorer/browse"}, + "crawler": {path: "crawler"}, + "connectors": {path: "connectors"}, + "api-keys": {path: "account/api-keys/all", accountScoped: true}, + "usage": {path: "account/billing/usage", accountScoped: true}, + "team": {path: "account/teams", accountScoped: true}, + "billing": {path: "account/billing/details", accountScoped: true}, +} + +// purposeOrder controls the display order for the interactive picker, the +// flag help text, and shell completion. +var purposeOrder = []string{ + "dashboard", + "indices", + "crawler", + "connectors", + "api-keys", + "usage", + "team", + "billing", +} + +// DeeplinkOptions holds everything the deeplink command needs. The function +// fields are injected so the flow can be exercised without a real OAuth +// session or browser. +type DeeplinkOptions struct { + IO *iostreams.IOStreams + Config config.IConfig + + Purpose string + + Authenticate func(*iostreams.IOStreams, *dashboard.Client) (string, error) + SelectApplication func() (*dashboard.Application, error) + NewDashboardClient func(clientID string) *dashboard.Client + Browser func(string) error +} + +func NewDeeplinkCmd(f *cmdutil.Factory) *cobra.Command { + opts := &DeeplinkOptions{ + IO: f.IOStreams, + Config: f.Config, + Authenticate: auth.EnsureAuthenticated, + SelectApplication: func() (*dashboard.Application, error) { + return selectapp.Run(f) + }, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + Browser: open.Browser, + } + + cmd := &cobra.Command{ + Use: "deeplink", + Short: "Open a dashboard page for the current application", + Long: heredoc.Doc(` + Open a specific page of the Algolia dashboard in your browser, + scoped to the current application.`), + Example: heredoc.Doc(` + # Choose a destination from a list + $ algolia deeplink + + # Open the API keys page for the current application + $ algolia deeplink --purpose api-keys + + # Open billing / payment details + $ algolia deeplink --purpose billing + `), + Args: validators.NoArgs(), + Annotations: map[string]string{ + // The command manages its own sign-in and application resolution. + "skipAuthCheck": "true", + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeeplinkCmd(opts) + }, + } + + cmd.Flags().StringVar( + &opts.Purpose, + "purpose", + "", + fmt.Sprintf("Dashboard destination to open (%s)", strings.Join(purposeOrder, ", ")), + ) + _ = cmd.RegisterFlagCompletionFunc( + "purpose", + func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return purposeOrder, cobra.ShellCompDirectiveNoFileComp + }, + ) + + return cmd +} + +func runDeeplinkCmd(opts *DeeplinkOptions) error { + // Resolve the destination first so invalid or missing input fails before + // any sign-in or browser side effects. + purpose, err := opts.resolvePurpose() + if err != nil { + return err + } + + // Require a valid sign-in even when an application is already configured. + client := opts.NewDashboardClient(auth.OAuthClientID()) + if _, err := opts.Authenticate(opts.IO, client); err != nil { + return err + } + + appID, err := opts.Config.Profile().GetApplicationID() + if err != nil { + app, selErr := opts.SelectApplication() + if selErr != nil { + return selErr + } + if app == nil { + // No application is available to scope to; the selection flow has + // already explained the situation to the user. + return nil + } + appID = app.ID + } + + // The base URL is resolved from ALGOLIA_DASHBOARD_URL by the dashboard + // client (falling back to its compiled-in default). + url := deeplinkURL(client.DashboardURL, appID, purpose) + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "Opening %s\n", cs.Bold(url)) + + return opts.Browser(url) +} + +// resolvePurpose validates an explicit --purpose value or, when omitted, +// prompts for one interactively. In non-interactive mode without a value it +// returns a flag error listing the valid destinations. +func (opts *DeeplinkOptions) resolvePurpose() (string, error) { + if opts.Purpose != "" { + if _, ok := purposeTargets[opts.Purpose]; !ok { + return "", cmdutil.FlagErrorf( + "invalid purpose %q: must be one of %s", + opts.Purpose, + strings.Join(purposeOrder, ", "), + ) + } + return opts.Purpose, nil + } + + if !opts.IO.CanPrompt() { + return "", cmdutil.FlagErrorf( + "--purpose is required in non-interactive mode: must be one of %s", + strings.Join(purposeOrder, ", "), + ) + } + + var selected int + err := prompt.SurveyAskOne( + &survey.Select{ + Message: "Open which dashboard page?", + Options: purposeOrder, + }, + &selected, + ) + if err != nil { + return "", err + } + + return purposeOrder[selected], nil +} + +// deeplinkURL builds the dashboard URL for an application and purpose, using +// baseURL (resolved from ALGOLIA_DASHBOARD_URL) as the host. Application pages +// are scoped via the /apps/{appID} path; account pages carry the application +// in an applicationId query parameter. +func deeplinkURL(baseURL, appID, purpose string) string { + target := purposeTargets[purpose] + if target.accountScoped { + return fmt.Sprintf("%s/%s?applicationId=%s", baseURL, target.path, url.QueryEscape(appID)) + } + + return fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, target.path) +} diff --git a/pkg/cmd/deeplink/deeplink_test.go b/pkg/cmd/deeplink/deeplink_test.go new file mode 100644 index 00000000..d643c9af --- /dev/null +++ b/pkg/cmd/deeplink/deeplink_test.go @@ -0,0 +1,173 @@ +package deeplink + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func TestDeeplinkURL(t *testing.T) { + base := "https://dashboard.algolia.com" + tests := []struct { + purpose string + want string + }{ + {"dashboard", "https://dashboard.algolia.com/apps/APP123/dashboard"}, + {"indices", "https://dashboard.algolia.com/apps/APP123/explorer/browse"}, + {"crawler", "https://dashboard.algolia.com/apps/APP123/crawler"}, + {"connectors", "https://dashboard.algolia.com/apps/APP123/connectors"}, + {"api-keys", "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123"}, + {"usage", "https://dashboard.algolia.com/account/billing/usage?applicationId=APP123"}, + {"team", "https://dashboard.algolia.com/account/teams?applicationId=APP123"}, + {"billing", "https://dashboard.algolia.com/account/billing/details?applicationId=APP123"}, + } + + for _, tt := range tests { + t.Run(tt.purpose, func(t *testing.T) { + assert.Equal(t, tt.want, deeplinkURL(base, "APP123", tt.purpose)) + }) + } +} + +// TestPurposeOrderMatchesTargets guards against the ordered list and the +// target map drifting apart. +func TestPurposeOrderMatchesTargets(t *testing.T) { + assert.Len(t, purposeOrder, len(purposeTargets)) + for _, p := range purposeOrder { + _, ok := purposeTargets[p] + assert.Truef(t, ok, "purpose %q listed in order but has no target", p) + } +} + +func newTestOptions( + io *iostreams.IOStreams, + cfg config.IConfig, +) (*DeeplinkOptions, *string, *bool) { + opened := new(string) + authed := new(bool) + + opts := &DeeplinkOptions{ + IO: io, + Config: cfg, + Authenticate: func(_ *iostreams.IOStreams, _ *dashboard.Client) (string, error) { + *authed = true + return "test-token", nil + }, + SelectApplication: func() (*dashboard.Application, error) { + return nil, errors.New("SelectApplication should not be called") + }, + NewDashboardClient: func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://dashboard.algolia.com"} + }, + Browser: func(u string) error { + *opened = u + return nil + }, + } + + return opts, opened, authed +} + +func TestRunDeeplinkCmd_ConfiguredApp(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Purpose = "api-keys" + + err := runDeeplinkCmd(opts) + require.NoError(t, err) + assert.True(t, *authed, "expected sign-in to be required") + assert.Equal( + t, + "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123", + *opened, + ) +} + +func TestRunDeeplinkCmd_UsesConfiguredDashboardURL(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Purpose = "usage" + opts.NewDashboardClient = func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} + } + + err := runDeeplinkCmd(opts) + require.NoError(t, err) + assert.Equal( + t, + "https://staging.algolia.test/account/billing/usage?applicationId=APP123", + *opened, + ) +} + +func TestRunDeeplinkCmd_SelectsAppWhenNoneConfigured(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Purpose = "dashboard" + opts.SelectApplication = func() (*dashboard.Application, error) { + return &dashboard.Application{ID: "SELECTED", Name: "Picked"}, nil + } + + err := runDeeplinkCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://dashboard.algolia.com/apps/SELECTED/dashboard", *opened) +} + +func TestRunDeeplinkCmd_InvalidPurpose(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Purpose = "bogus" + + err := runDeeplinkCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid purpose") + assert.False(t, *authed, "should fail before sign-in") + assert.Empty(t, *opened, "browser should not be opened") +} + +func TestRunDeeplinkCmd_NoPurposeNonInteractive(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(false) + io.SetStdinTTY(false) + + opts, opened, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Purpose = "" + + err := runDeeplinkCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "--purpose is required") + assert.Empty(t, *opened, "browser should not be opened") +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index e1d5a669..3abfcbfe 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -26,6 +26,7 @@ import ( authcmd "github.com/algolia/cli/pkg/cmd/auth" "github.com/algolia/cli/pkg/cmd/compositions" "github.com/algolia/cli/pkg/cmd/crawler" + "github.com/algolia/cli/pkg/cmd/deeplink" "github.com/algolia/cli/pkg/cmd/describe" "github.com/algolia/cli/pkg/cmd/dictionary" "github.com/algolia/cli/pkg/cmd/events" @@ -103,6 +104,7 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { // Convenience commands cmd.AddCommand(open.NewOpenCmd(f)) + cmd.AddCommand(deeplink.NewDeeplinkCmd(f)) // API related commands cmd.AddCommand(application.NewApplicationCmd(f)) From 29ff6ad4b63980120717a9c199e650874f384254 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 1 Jun 2026 09:50:11 -0700 Subject: [PATCH 2/3] feat(open): merge deeplinks into open command --- pkg/cmd/application/selectapp/select.go | 2 +- pkg/cmd/deeplink/deeplink.go | 215 -------------- pkg/cmd/deeplink/deeplink_test.go | 173 ------------ pkg/cmd/open/open.go | 357 ++++++++++++++++++------ pkg/cmd/open/open_test.go | 345 +++++++++++++++++++++++ pkg/cmd/root/root.go | 2 - 6 files changed, 618 insertions(+), 476 deletions(-) delete mode 100644 pkg/cmd/deeplink/deeplink.go delete mode 100644 pkg/cmd/deeplink/deeplink_test.go create mode 100644 pkg/cmd/open/open_test.go diff --git a/pkg/cmd/application/selectapp/select.go b/pkg/cmd/application/selectapp/select.go index 7acce3b3..ad69d089 100644 --- a/pkg/cmd/application/selectapp/select.go +++ b/pkg/cmd/application/selectapp/select.go @@ -70,7 +70,7 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command { } // Run executes the interactive application-selection flow and returns the -// chosen application. Other commands (e.g. deeplink) use it to ensure an +// chosen application. Other commands (e.g. open) use it to ensure an // application is selected before proceeding. A nil application is returned // when the account has no applications. func Run(f *cmdutil.Factory) (*dashboard.Application, error) { diff --git a/pkg/cmd/deeplink/deeplink.go b/pkg/cmd/deeplink/deeplink.go deleted file mode 100644 index 273f9e4e..00000000 --- a/pkg/cmd/deeplink/deeplink.go +++ /dev/null @@ -1,215 +0,0 @@ -package deeplink - -import ( - "fmt" - "net/url" - "strings" - - "github.com/AlecAivazis/survey/v2" - "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/cmd/application/selectapp" - "github.com/algolia/cli/pkg/cmdutil" - "github.com/algolia/cli/pkg/config" - "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/open" - "github.com/algolia/cli/pkg/prompt" - "github.com/algolia/cli/pkg/validators" -) - -// purposeTarget describes how to build the dashboard URL for a --purpose value. -type purposeTarget struct { - // path is the dashboard path for the destination. - path string - // accountScoped marks account-level pages. They live at - // {base}/{path}?applicationId={appID}, whereas application pages live at - // {base}/apps/{appID}/{path}. - accountScoped bool -} - -// purposeTargets maps each --purpose value to its dashboard destination. -var purposeTargets = map[string]purposeTarget{ - "dashboard": {path: "dashboard"}, - "indices": {path: "explorer/browse"}, - "crawler": {path: "crawler"}, - "connectors": {path: "connectors"}, - "api-keys": {path: "account/api-keys/all", accountScoped: true}, - "usage": {path: "account/billing/usage", accountScoped: true}, - "team": {path: "account/teams", accountScoped: true}, - "billing": {path: "account/billing/details", accountScoped: true}, -} - -// purposeOrder controls the display order for the interactive picker, the -// flag help text, and shell completion. -var purposeOrder = []string{ - "dashboard", - "indices", - "crawler", - "connectors", - "api-keys", - "usage", - "team", - "billing", -} - -// DeeplinkOptions holds everything the deeplink command needs. The function -// fields are injected so the flow can be exercised without a real OAuth -// session or browser. -type DeeplinkOptions struct { - IO *iostreams.IOStreams - Config config.IConfig - - Purpose string - - Authenticate func(*iostreams.IOStreams, *dashboard.Client) (string, error) - SelectApplication func() (*dashboard.Application, error) - NewDashboardClient func(clientID string) *dashboard.Client - Browser func(string) error -} - -func NewDeeplinkCmd(f *cmdutil.Factory) *cobra.Command { - opts := &DeeplinkOptions{ - IO: f.IOStreams, - Config: f.Config, - Authenticate: auth.EnsureAuthenticated, - SelectApplication: func() (*dashboard.Application, error) { - return selectapp.Run(f) - }, - NewDashboardClient: func(clientID string) *dashboard.Client { - return dashboard.NewClient(clientID) - }, - Browser: open.Browser, - } - - cmd := &cobra.Command{ - Use: "deeplink", - Short: "Open a dashboard page for the current application", - Long: heredoc.Doc(` - Open a specific page of the Algolia dashboard in your browser, - scoped to the current application.`), - Example: heredoc.Doc(` - # Choose a destination from a list - $ algolia deeplink - - # Open the API keys page for the current application - $ algolia deeplink --purpose api-keys - - # Open billing / payment details - $ algolia deeplink --purpose billing - `), - Args: validators.NoArgs(), - Annotations: map[string]string{ - // The command manages its own sign-in and application resolution. - "skipAuthCheck": "true", - }, - RunE: func(cmd *cobra.Command, args []string) error { - return runDeeplinkCmd(opts) - }, - } - - cmd.Flags().StringVar( - &opts.Purpose, - "purpose", - "", - fmt.Sprintf("Dashboard destination to open (%s)", strings.Join(purposeOrder, ", ")), - ) - _ = cmd.RegisterFlagCompletionFunc( - "purpose", - func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return purposeOrder, cobra.ShellCompDirectiveNoFileComp - }, - ) - - return cmd -} - -func runDeeplinkCmd(opts *DeeplinkOptions) error { - // Resolve the destination first so invalid or missing input fails before - // any sign-in or browser side effects. - purpose, err := opts.resolvePurpose() - if err != nil { - return err - } - - // Require a valid sign-in even when an application is already configured. - client := opts.NewDashboardClient(auth.OAuthClientID()) - if _, err := opts.Authenticate(opts.IO, client); err != nil { - return err - } - - appID, err := opts.Config.Profile().GetApplicationID() - if err != nil { - app, selErr := opts.SelectApplication() - if selErr != nil { - return selErr - } - if app == nil { - // No application is available to scope to; the selection flow has - // already explained the situation to the user. - return nil - } - appID = app.ID - } - - // The base URL is resolved from ALGOLIA_DASHBOARD_URL by the dashboard - // client (falling back to its compiled-in default). - url := deeplinkURL(client.DashboardURL, appID, purpose) - - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "Opening %s\n", cs.Bold(url)) - - return opts.Browser(url) -} - -// resolvePurpose validates an explicit --purpose value or, when omitted, -// prompts for one interactively. In non-interactive mode without a value it -// returns a flag error listing the valid destinations. -func (opts *DeeplinkOptions) resolvePurpose() (string, error) { - if opts.Purpose != "" { - if _, ok := purposeTargets[opts.Purpose]; !ok { - return "", cmdutil.FlagErrorf( - "invalid purpose %q: must be one of %s", - opts.Purpose, - strings.Join(purposeOrder, ", "), - ) - } - return opts.Purpose, nil - } - - if !opts.IO.CanPrompt() { - return "", cmdutil.FlagErrorf( - "--purpose is required in non-interactive mode: must be one of %s", - strings.Join(purposeOrder, ", "), - ) - } - - var selected int - err := prompt.SurveyAskOne( - &survey.Select{ - Message: "Open which dashboard page?", - Options: purposeOrder, - }, - &selected, - ) - if err != nil { - return "", err - } - - return purposeOrder[selected], nil -} - -// deeplinkURL builds the dashboard URL for an application and purpose, using -// baseURL (resolved from ALGOLIA_DASHBOARD_URL) as the host. Application pages -// are scoped via the /apps/{appID} path; account pages carry the application -// in an applicationId query parameter. -func deeplinkURL(baseURL, appID, purpose string) string { - target := purposeTargets[purpose] - if target.accountScoped { - return fmt.Sprintf("%s/%s?applicationId=%s", baseURL, target.path, url.QueryEscape(appID)) - } - - return fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, target.path) -} diff --git a/pkg/cmd/deeplink/deeplink_test.go b/pkg/cmd/deeplink/deeplink_test.go deleted file mode 100644 index d643c9af..00000000 --- a/pkg/cmd/deeplink/deeplink_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package deeplink - -import ( - "errors" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/algolia/cli/api/dashboard" - "github.com/algolia/cli/pkg/config" - "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/test" -) - -func TestDeeplinkURL(t *testing.T) { - base := "https://dashboard.algolia.com" - tests := []struct { - purpose string - want string - }{ - {"dashboard", "https://dashboard.algolia.com/apps/APP123/dashboard"}, - {"indices", "https://dashboard.algolia.com/apps/APP123/explorer/browse"}, - {"crawler", "https://dashboard.algolia.com/apps/APP123/crawler"}, - {"connectors", "https://dashboard.algolia.com/apps/APP123/connectors"}, - {"api-keys", "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123"}, - {"usage", "https://dashboard.algolia.com/account/billing/usage?applicationId=APP123"}, - {"team", "https://dashboard.algolia.com/account/teams?applicationId=APP123"}, - {"billing", "https://dashboard.algolia.com/account/billing/details?applicationId=APP123"}, - } - - for _, tt := range tests { - t.Run(tt.purpose, func(t *testing.T) { - assert.Equal(t, tt.want, deeplinkURL(base, "APP123", tt.purpose)) - }) - } -} - -// TestPurposeOrderMatchesTargets guards against the ordered list and the -// target map drifting apart. -func TestPurposeOrderMatchesTargets(t *testing.T) { - assert.Len(t, purposeOrder, len(purposeTargets)) - for _, p := range purposeOrder { - _, ok := purposeTargets[p] - assert.Truef(t, ok, "purpose %q listed in order but has no target", p) - } -} - -func newTestOptions( - io *iostreams.IOStreams, - cfg config.IConfig, -) (*DeeplinkOptions, *string, *bool) { - opened := new(string) - authed := new(bool) - - opts := &DeeplinkOptions{ - IO: io, - Config: cfg, - Authenticate: func(_ *iostreams.IOStreams, _ *dashboard.Client) (string, error) { - *authed = true - return "test-token", nil - }, - SelectApplication: func() (*dashboard.Application, error) { - return nil, errors.New("SelectApplication should not be called") - }, - NewDashboardClient: func(string) *dashboard.Client { - return &dashboard.Client{DashboardURL: "https://dashboard.algolia.com"} - }, - Browser: func(u string) error { - *opened = u - return nil - }, - } - - return opts, opened, authed -} - -func TestRunDeeplinkCmd_ConfiguredApp(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - - cfg := test.NewConfigStubWithProfiles([]*config.Profile{ - {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, - }) - - opts, opened, authed := newTestOptions(io, cfg) - opts.Purpose = "api-keys" - - err := runDeeplinkCmd(opts) - require.NoError(t, err) - assert.True(t, *authed, "expected sign-in to be required") - assert.Equal( - t, - "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123", - *opened, - ) -} - -func TestRunDeeplinkCmd_UsesConfiguredDashboardURL(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - - cfg := test.NewConfigStubWithProfiles([]*config.Profile{ - {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, - }) - - opts, opened, _ := newTestOptions(io, cfg) - opts.Purpose = "usage" - opts.NewDashboardClient = func(string) *dashboard.Client { - return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} - } - - err := runDeeplinkCmd(opts) - require.NoError(t, err) - assert.Equal( - t, - "https://staging.algolia.test/account/billing/usage?applicationId=APP123", - *opened, - ) -} - -func TestRunDeeplinkCmd_SelectsAppWhenNoneConfigured(t *testing.T) { - t.Setenv("ALGOLIA_APPLICATION_ID", "") - - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - - cfg := test.NewConfigStubWithProfiles([]*config.Profile{ - {Name: "default", ApplicationID: "", Default: true}, - }) - - opts, opened, _ := newTestOptions(io, cfg) - opts.Purpose = "dashboard" - opts.SelectApplication = func() (*dashboard.Application, error) { - return &dashboard.Application{ID: "SELECTED", Name: "Picked"}, nil - } - - err := runDeeplinkCmd(opts) - require.NoError(t, err) - assert.Equal(t, "https://dashboard.algolia.com/apps/SELECTED/dashboard", *opened) -} - -func TestRunDeeplinkCmd_InvalidPurpose(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(true) - io.SetStdinTTY(true) - - opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) - opts.Purpose = "bogus" - - err := runDeeplinkCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid purpose") - assert.False(t, *authed, "should fail before sign-in") - assert.Empty(t, *opened, "browser should not be opened") -} - -func TestRunDeeplinkCmd_NoPurposeNonInteractive(t *testing.T) { - io, _, _, _ := iostreams.Test() - io.SetStdoutTTY(false) - io.SetStdinTTY(false) - - opts, opened, _ := newTestOptions(io, test.NewDefaultConfigStub()) - opts.Purpose = "" - - err := runDeeplinkCmd(opts) - require.Error(t, err) - assert.Contains(t, err.Error(), "--purpose is required") - assert.Empty(t, *opened, "browser should not be opened") -} diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index 2118e6ce..5da27be8 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -7,106 +7,158 @@ import ( "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/cmd/application/selectapp" "github.com/algolia/cli/pkg/cmdutil" "github.com/algolia/cli/pkg/config" "github.com/algolia/cli/pkg/iostreams" - "github.com/algolia/cli/pkg/open" + pkgopen "github.com/algolia/cli/pkg/open" "github.com/algolia/cli/pkg/printers" ) -type OpenURL struct { - Default string - WithAppID string +// resourceURL is a static shortcut that does not require sign-in. +type resourceURL struct { + // Default is the absolute URL used when no application is configured. + Default string + // AppPath, when set, is the dashboard path used when an application is + // configured. It is resolved against ALGOLIA_DASHBOARD_URL as + // {base}/apps/{appID}/{AppPath}. + AppPath string } -var openURLMap = map[string]OpenURL{ - "api": {Default: "https://www.algolia.com/doc/api-reference/rest-api/"}, - "codex": {Default: "https://www.algolia.com/developers/code-exchange/"}, - "cli-docs": {Default: "https://algolia.com/doc/tools/cli/get-started/overview/"}, - "cli-repo": {Default: "https://github.com/algolia/cli"}, - "dashboard": { - Default: "https://www.algolia.com/dashboard", - WithAppID: "https://www.algolia.com/apps/%s/dashboard", - }, +var resourceURLs = map[string]resourceURL{ + "api": {Default: "https://www.algolia.com/doc/api-reference/rest-api/"}, + "codex": {Default: "https://www.algolia.com/developers/code-exchange/"}, + "cli-docs": {Default: "https://algolia.com/doc/tools/cli/get-started/overview/"}, + "cli-repo": {Default: "https://github.com/algolia/cli"}, "devhub": {Default: "https://www.algolia.com/developers/"}, "docs": {Default: "https://algolia.com/doc/"}, "languages": {Default: "https://alg.li/supported-languages"}, "status": { - Default: "https://status.algolia.com/", - WithAppID: "https://www.algolia.com/apps/%s/monitoring/status", + Default: "https://status.algolia.com/", + AppPath: "monitoring/status", }, } -func openNames() []string { - keys := make([]string, 0, len(openURLMap)) - for k := range openURLMap { - keys = append(keys, k) - } +// dashboardTarget is an application dashboard page. These require sign-in and +// are scoped to the current application (selecting one if none is configured). +type dashboardTarget struct { + // path is the dashboard path for the destination. + path string + // accountScoped marks account-level pages. They live at + // {base}/{path}?applicationId={appID}, whereas application pages live at + // {base}/apps/{appID}/{path}. + accountScoped bool +} - return keys +var dashboardTargets = map[string]dashboardTarget{ + "dashboard": {path: "dashboard"}, + "indices": {path: "explorer/browse"}, + "crawler": {path: "crawler"}, + "connectors": {path: "connectors"}, + "api-keys": {path: "account/api-keys/all", accountScoped: true}, + "usage": {path: "account/billing/usage", accountScoped: true}, + "team": {path: "account/teams", accountScoped: true}, + "billing": {path: "account/billing/details", accountScoped: true}, } -func getNameURLMap(applicationID string) map[string]string { - nameURLMap := make(map[string]string) - for _, openName := range openNames() { - url := openURLMap[openName].Default - if applicationID != "" && openURLMap[openName].WithAppID != "" { - url = fmt.Sprintf(openURLMap[openName].WithAppID, applicationID) - } - nameURLMap[openName] = url +// targetNames returns every supported shortcut, sorted. +func targetNames() []string { + names := make([]string, 0, len(resourceURLs)+len(dashboardTargets)) + for name := range resourceURLs { + names = append(names, name) + } + for name := range dashboardTargets { + names = append(names, name) } + sort.Strings(names) - return nameURLMap + return names } -// OpenOptions represents the options for the open command +// pageEntry describes an open shortcut for machine-readable output. +type pageEntry struct { + Shortcut string `json:"shortcut"` + URL string `json:"url"` + RequiresLogin bool `json:"requiresLogin"` +} + +// OpenOptions represents the options for the open command. The function fields +// are injected so the flow can be exercised without a real OAuth session or +// browser. type OpenOptions struct { config config.IConfig IO *iostreams.IOStreams List bool Shortcut string + + PrintFlags *cmdutil.PrintFlags + + Authenticate func(*iostreams.IOStreams, *dashboard.Client) (string, error) + SelectApplication func() (*dashboard.Application, error) + NewDashboardClient func(clientID string) *dashboard.Client + Browser func(string) error } func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { opts := &OpenOptions{ - IO: f.IOStreams, - config: f.Config, + IO: f.IOStreams, + config: f.Config, + PrintFlags: cmdutil.NewPrintFlags(), + Authenticate: auth.EnsureAuthenticated, + SelectApplication: func() (*dashboard.Application, error) { + return selectapp.Run(f) + }, + NewDashboardClient: func(clientID string) *dashboard.Client { + return dashboard.NewClient(clientID) + }, + Browser: pkgopen.Browser, } + cmd := &cobra.Command{ Use: "open ", - ValidArgs: openNames(), + ValidArgs: targetNames(), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - if opts.List { - return nil, cobra.ShellCompDirectiveNoFileComp - } - return openNames(), cobra.ShellCompDirectiveNoFileComp + return targetNames(), cobra.ShellCompDirectiveNoFileComp }, - Short: "Access Algolia support resources", - Long: `The open command provides links to Algolia support resources. 'algolia open --list' for a list of support links.`, + Short: "Open Algolia pages in your browser", + Long: heredoc.Doc(` + Open Algolia pages in your browser. + + Resource shortcuts (docs, API reference, status, …) open directly. + + Application pages (dashboard, indices, crawler, connectors, api-keys, + usage, team, billing) are scoped to the current application: they + require you to be signed in, and prompt you to select an application + if none is configured. + + Run 'algolia open --list' to see every shortcut. + + With an output format (--output), the resolved page links are printed + instead of opening a browser. + `), Example: heredoc.Doc(` - # The support links + # List all shortcuts $ algolia open --list - # The Algolia dashboard for the current application - $ algolia open dashboard - - # The Algolia REST APIs - $ algolia open api - - # The Algolia documentation home page + # List all shortcuts as JSON + $ algolia open --list --output json + + # Open the documentation home page $ algolia open docs - # The Algolia CLI documentation - $ algolia open cli-docs + # Open the dashboard for the current application + $ algolia open dashboard - # Algolia's status page - $ algolia open status + # Open billing / payment details for the current application + $ algolia open billing - # Algolia's supported languages page - $ algolia open languages + # Print a page link as JSON instead of opening it + $ algolia open billing --output json `), + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) > 0 { opts.Shortcut = args[0] @@ -115,7 +167,8 @@ func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { }, } - cmd.Flags().BoolP("list", "l", false, "List all support links") + cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "List all shortcuts") + opts.PrintFlags.AddFlags(cmd) auth.DisableAuthCheck(cmd) @@ -123,43 +176,177 @@ func NewOpenCmd(f *cmdutil.Factory) *cobra.Command { } func runOpenCmd(opts *OpenOptions) error { - profile := opts.config.Profile() - applicationID, _ := profile.GetApplicationID() - nameURLMap := getNameURLMap(applicationID) - - if opts.List || opts.Shortcut == "" { - fmt.Println("open quickly opens Algolia pages. To use, run 'algolia open '.") - fmt.Println("open supports the following shortcuts:") - fmt.Println() - - shortcuts := openNames() - sort.Strings(shortcuts) - - table := printers.NewTablePrinter(opts.IO) - if table.IsTTY() { - table.AddField("SHORTCUT", nil, nil) - table.AddField("URL", nil, nil) - table.EndRow() + listing := opts.List || opts.Shortcut == "" + + // With an output format, emit page metadata instead of opening a browser. + if opts.structuredOutput() { + return printTargets(opts, listing) + } + + if listing { + return listTargets(opts) + } + + // Resource shortcuts open directly, without sign-in. + if resource, ok := resourceURLs[opts.Shortcut]; ok { + appID, _ := opts.config.Profile().GetApplicationID() + url := resource.Default + if appID != "" && resource.AppPath != "" { + baseURL := opts.NewDashboardClient(auth.OAuthClientID()).DashboardURL + url = fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, resource.AppPath) + } + return opts.Browser(url) + } + + // Application pages require sign-in and an application scope. + if target, ok := dashboardTargets[opts.Shortcut]; ok { + return openDashboardTarget(opts, target) + } + + return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) +} + +// structuredOutput reports whether an output format was requested via --output. +func (opts *OpenOptions) structuredOutput() bool { + return opts.PrintFlags != nil && + opts.PrintFlags.OutputFlagSpecified != nil && + opts.PrintFlags.OutputFlagSpecified() +} + +// printTargets renders page metadata with the configured printer. When listing, +// every shortcut is printed; otherwise only the requested shortcut is printed. +func printTargets(opts *OpenOptions, listing bool) error { + printer, err := opts.PrintFlags.ToPrinter() + if err != nil { + return err + } + + if listing { + return printer.Print(opts.IO, opts.allEntries()) + } + + baseURL, appID, displayAppID := opts.resolveScope() + entry, ok := entryFor(opts.Shortcut, baseURL, appID, displayAppID) + if !ok { + return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) + } + + return printer.Print(opts.IO, entry) +} + +// resolveScope returns the dashboard base URL and the application id used to +// build dashboard links. displayAppID falls back to a placeholder so links can +// be shown even when no application is configured. +func (opts *OpenOptions) resolveScope() (baseURL, appID, displayAppID string) { + appID, _ = opts.config.Profile().GetApplicationID() + displayAppID = appID + if displayAppID == "" { + displayAppID = "" + } + baseURL = opts.NewDashboardClient(auth.OAuthClientID()).DashboardURL + + return baseURL, appID, displayAppID +} + +// entryFor builds the page entry for a shortcut, or returns false if the +// shortcut is unknown. +func entryFor(name, baseURL, appID, displayAppID string) (pageEntry, bool) { + if resource, ok := resourceURLs[name]; ok { + url := resource.Default + if appID != "" && resource.AppPath != "" { + url = fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, resource.AppPath) } + return pageEntry{Shortcut: name, URL: url}, true + } + + if target, ok := dashboardTargets[name]; ok { + return pageEntry{ + Shortcut: name, + URL: dashboardURL(baseURL, displayAppID, target), + RequiresLogin: true, + }, true + } + + return pageEntry{}, false +} + +// allEntries returns every shortcut, sorted by name. +func (opts *OpenOptions) allEntries() []pageEntry { + baseURL, appID, displayAppID := opts.resolveScope() - for shortcutName, url := range nameURLMap { - table.AddField(shortcutName, nil, nil) - table.AddField(url, nil, nil) - table.EndRow() + entries := make([]pageEntry, 0, len(resourceURLs)+len(dashboardTargets)) + for _, name := range targetNames() { + if entry, ok := entryFor(name, baseURL, appID, displayAppID); ok { + entries = append(entries, entry) } + } + + return entries +} - return table.Render() +// openDashboardTarget signs the user in, resolves the current application +// (selecting one if needed), then opens the dashboard page. +func openDashboardTarget(opts *OpenOptions, target dashboardTarget) error { + client := opts.NewDashboardClient(auth.OAuthClientID()) + if _, err := opts.Authenticate(opts.IO, client); err != nil { + return err } - var err error - if url, ok := nameURLMap[opts.Shortcut]; ok { - err = open.Browser(url) - if err != nil { - return err + appID, err := opts.config.Profile().GetApplicationID() + if err != nil { + app, selErr := opts.SelectApplication() + if selErr != nil { + return selErr } - } else { - return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) + if app == nil { + // No application is available to scope to; the selection flow has + // already explained the situation to the user. + return nil + } + appID = app.ID + } + + // The base URL is resolved from ALGOLIA_DASHBOARD_URL by the dashboard + // client (falling back to its compiled-in default). + url := dashboardURL(client.DashboardURL, appID, target) + + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "Opening %s\n", cs.Bold(url)) + + return opts.Browser(url) +} + +// dashboardURL builds the dashboard URL for an application page. Application +// pages are scoped via the /apps/{appID} path; account pages carry the +// application in an applicationId query parameter. +func dashboardURL(baseURL, appID string, target dashboardTarget) string { + if target.accountScoped { + return fmt.Sprintf("%s/%s?applicationId=%s", baseURL, target.path, appID) + } + + return fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, target.path) +} + +func listTargets(opts *OpenOptions) error { + fmt.Fprintln( + opts.IO.Out, + "open quickly opens Algolia pages. To use, run 'algolia open '.", + ) + fmt.Fprintln(opts.IO.Out, "open supports the following shortcuts:") + fmt.Fprintln(opts.IO.Out) + + table := printers.NewTablePrinter(opts.IO) + if table.IsTTY() { + table.AddField("SHORTCUT", nil, nil) + table.AddField("URL", nil, nil) + table.EndRow() + } + + for _, entry := range opts.allEntries() { + table.AddField(entry.Shortcut, nil, nil) + table.AddField(entry.URL, nil, nil) + table.EndRow() } - return nil + return table.Render() } diff --git a/pkg/cmd/open/open_test.go b/pkg/cmd/open/open_test.go new file mode 100644 index 00000000..dae93f01 --- /dev/null +++ b/pkg/cmd/open/open_test.go @@ -0,0 +1,345 @@ +package open + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/dashboard" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/config" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/test" +) + +func TestDashboardURL(t *testing.T) { + base := "https://dashboard.algolia.com" + tests := []struct { + shortcut string + want string + }{ + {"dashboard", "https://dashboard.algolia.com/apps/APP123/dashboard"}, + {"indices", "https://dashboard.algolia.com/apps/APP123/explorer/browse"}, + {"crawler", "https://dashboard.algolia.com/apps/APP123/crawler"}, + {"connectors", "https://dashboard.algolia.com/apps/APP123/connectors"}, + {"api-keys", "https://dashboard.algolia.com/account/api-keys/all?applicationId=APP123"}, + {"usage", "https://dashboard.algolia.com/account/billing/usage?applicationId=APP123"}, + {"team", "https://dashboard.algolia.com/account/teams?applicationId=APP123"}, + {"billing", "https://dashboard.algolia.com/account/billing/details?applicationId=APP123"}, + } + + for _, tt := range tests { + t.Run(tt.shortcut, func(t *testing.T) { + assert.Equal(t, tt.want, dashboardURL(base, "APP123", dashboardTargets[tt.shortcut])) + }) + } +} + +// TestTargetNamesNoCollision guards against a shortcut existing in both maps, +// which would make its behavior ambiguous. +func TestTargetNamesNoCollision(t *testing.T) { + for name := range dashboardTargets { + _, dup := resourceURLs[name] + assert.Falsef(t, dup, "shortcut %q exists in both resourceURLs and dashboardTargets", name) + } +} + +func newTestOptions( + io *iostreams.IOStreams, + cfg config.IConfig, +) (*OpenOptions, *string, *bool) { + opened := new(string) + authed := new(bool) + + opts := &OpenOptions{ + IO: io, + config: cfg, + PrintFlags: cmdutil.NewPrintFlags(), + Authenticate: func(_ *iostreams.IOStreams, _ *dashboard.Client) (string, error) { + *authed = true + return "test-token", nil + }, + SelectApplication: func() (*dashboard.Application, error) { + return nil, errors.New("SelectApplication should not be called") + }, + NewDashboardClient: func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://dashboard.algolia.com"} + }, + Browser: func(u string) error { + *opened = u + return nil + }, + } + + return opts, opened, authed +} + +// withOutputFormat configures opts to emit structured output, as the --output +// flag would when parsed by cobra. +func withOutputFormat(opts *OpenOptions, format string) { + *opts.PrintFlags.OutputFormat = format + opts.PrintFlags.OutputFlagSpecified = func() bool { return true } +} + +func TestRunOpenCmd_ResourceShortcutNoAuth(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "docs" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.False(t, *authed, "resource shortcuts must not require sign-in") + assert.Equal(t, "https://algolia.com/doc/", *opened) +} + +func TestRunOpenCmd_ResourceShortcutWithAppID(t *testing.T) { + io, _, _, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "status" + opts.NewDashboardClient = func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.False(t, *authed, "status must not require sign-in") + assert.Equal(t, "https://staging.algolia.test/apps/APP123/monitoring/status", *opened) +} + +func TestRunOpenCmd_ResourceShortcutNoAppUsesDefault(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "status" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://status.algolia.com/", *opened) +} + +func TestRunOpenCmd_DashboardTargetConfiguredApp(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "billing" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.True(t, *authed, "application pages require sign-in") + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + *opened, + ) +} + +func TestRunOpenCmd_DashboardTargetAppScoped(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "dashboard" + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://dashboard.algolia.com/apps/APP123/dashboard", *opened) +} + +func TestRunOpenCmd_DashboardTargetUsesConfiguredDashboardURL(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "usage" + opts.NewDashboardClient = func(string) *dashboard.Client { + return &dashboard.Client{DashboardURL: "https://staging.algolia.test"} + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal( + t, + "https://staging.algolia.test/account/billing/usage?applicationId=APP123", + *opened, + ) +} + +func TestRunOpenCmd_DashboardTargetSelectsAppWhenNoneConfigured(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "", Default: true}, + }) + + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "dashboard" + opts.SelectApplication = func() (*dashboard.Application, error) { + return &dashboard.Application{ID: "SELECTED", Name: "Picked"}, nil + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.Equal(t, "https://dashboard.algolia.com/apps/SELECTED/dashboard", *opened) +} + +func TestRunOpenCmd_Unsupported(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, opened, authed := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "bogus" + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported open command") + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_ListIncludesBothKinds(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.List = true + + err := runOpenCmd(opts) + require.NoError(t, err) + + out := stdout.String() + assert.Contains(t, out, "docs") + assert.Contains(t, out, "billing") +} + +func TestRunOpenCmd_ListJSONOutput(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, stdout, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.List = true + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.NoError(t, err) + + var entries []pageEntry + require.NoError(t, json.Unmarshal(stdout.Bytes(), &entries)) + assert.Len(t, entries, len(resourceURLs)+len(dashboardTargets)) + + byName := make(map[string]pageEntry, len(entries)) + for _, e := range entries { + byName[e.Shortcut] = e + } + + assert.Equal( + t, + pageEntry{ + Shortcut: "billing", + URL: "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + RequiresLogin: true, + }, + byName["billing"], + ) + assert.Equal( + t, + pageEntry{Shortcut: "docs", URL: "https://algolia.com/doc/"}, + byName["docs"], + ) + + // Structured output never opens a browser or signs in. + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_SingleShortcutJSONOutput(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "APP123", APIKey: "key", Default: true}, + }) + + opts, opened, authed := newTestOptions(io, cfg) + opts.Shortcut = "billing" + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.NoError(t, err) + + var entry pageEntry + require.NoError(t, json.Unmarshal(stdout.Bytes(), &entry)) + assert.Equal( + t, + pageEntry{ + Shortcut: "billing", + URL: "https://dashboard.algolia.com/account/billing/details?applicationId=APP123", + RequiresLogin: true, + }, + entry, + ) + + // A dashboard target with --output does not sign in or open a browser. + assert.False(t, *authed) + assert.Empty(t, *opened) +} + +func TestRunOpenCmd_UnsupportedWithJSONOutput(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.Shortcut = "bogus" + withOutputFormat(opts, "json") + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported open command") +} + +func TestRunOpenCmd_InvalidOutputFormat(t *testing.T) { + io, _, _, _ := iostreams.Test() + + opts, _, _ := newTestOptions(io, test.NewDefaultConfigStub()) + opts.List = true + withOutputFormat(opts, "yaml") + + err := runOpenCmd(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "unable to match a printer") +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 3abfcbfe..e1d5a669 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -26,7 +26,6 @@ import ( authcmd "github.com/algolia/cli/pkg/cmd/auth" "github.com/algolia/cli/pkg/cmd/compositions" "github.com/algolia/cli/pkg/cmd/crawler" - "github.com/algolia/cli/pkg/cmd/deeplink" "github.com/algolia/cli/pkg/cmd/describe" "github.com/algolia/cli/pkg/cmd/dictionary" "github.com/algolia/cli/pkg/cmd/events" @@ -104,7 +103,6 @@ func NewRootCmd(f *cmdutil.Factory) *cobra.Command { // Convenience commands cmd.AddCommand(open.NewOpenCmd(f)) - cmd.AddCommand(deeplink.NewDeeplinkCmd(f)) // API related commands cmd.AddCommand(application.NewApplicationCmd(f)) From 59828f967f607aab806baf9907ec04632b51ff06 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Mon, 1 Jun 2026 10:06:11 -0700 Subject: [PATCH 3/3] fix(open): select application from list --- pkg/cmd/open/open.go | 126 ++++++++++++++++++++++++++++++++------ pkg/cmd/open/open_test.go | 45 +++++++++++++- 2 files changed, 149 insertions(+), 22 deletions(-) diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index 5da27be8..5a1fbf18 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -3,6 +3,7 @@ package open import ( "fmt" "sort" + "strings" "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" @@ -77,6 +78,14 @@ func targetNames() []string { return names } +func unsupportedShortcutError(shortcut string) error { + return fmt.Errorf( + "unsupported open command, given: %s\n\nAvailable shortcuts: %s", + shortcut, + strings.Join(targetNames(), ", "), + ) +} + // pageEntry describes an open shortcut for machine-readable output. type pageEntry struct { Shortcut string `json:"shortcut"` @@ -98,6 +107,7 @@ type OpenOptions struct { Authenticate func(*iostreams.IOStreams, *dashboard.Client) (string, error) SelectApplication func() (*dashboard.Application, error) + ListApplications func(*dashboard.Client, string) ([]dashboard.Application, error) NewDashboardClient func(clientID string) *dashboard.Client Browser func(string) error } @@ -187,13 +197,26 @@ func runOpenCmd(opts *OpenOptions) error { return listTargets(opts) } - // Resource shortcuts open directly, without sign-in. + // Resource shortcuts open directly, without sign-in. App-scoped resources + // (such as status) use the dashboard URL only when a profile application is + // configured and belongs to the signed-in account. if resource, ok := resourceURLs[opts.Shortcut]; ok { - appID, _ := opts.config.Profile().GetApplicationID() url := resource.Default - if appID != "" && resource.AppPath != "" { - baseURL := opts.NewDashboardClient(auth.OAuthClientID()).DashboardURL - url = fmt.Sprintf("%s/apps/%s/%s", baseURL, appID, resource.AppPath) + if resource.AppPath != "" { + if _, err := opts.config.Profile().GetApplicationID(); err == nil { + client := opts.NewDashboardClient(auth.OAuthClientID()) + accessToken, err := opts.Authenticate(opts.IO, client) + if err != nil { + return err + } + appID, err := opts.resolveApplicationForAccount(client, accessToken) + if err != nil { + return err + } + if appID != "" { + url = fmt.Sprintf("%s/apps/%s/%s", client.DashboardURL, appID, resource.AppPath) + } + } } return opts.Browser(url) } @@ -203,7 +226,7 @@ func runOpenCmd(opts *OpenOptions) error { return openDashboardTarget(opts, target) } - return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) + return unsupportedShortcutError(opts.Shortcut) } // structuredOutput reports whether an output format was requested via --output. @@ -228,7 +251,7 @@ func printTargets(opts *OpenOptions, listing bool) error { baseURL, appID, displayAppID := opts.resolveScope() entry, ok := entryFor(opts.Shortcut, baseURL, appID, displayAppID) if !ok { - return fmt.Errorf("unsupported open command, given: %s", opts.Shortcut) + return unsupportedShortcutError(opts.Shortcut) } return printer.Print(opts.IO, entry) @@ -288,22 +311,19 @@ func (opts *OpenOptions) allEntries() []pageEntry { // (selecting one if needed), then opens the dashboard page. func openDashboardTarget(opts *OpenOptions, target dashboardTarget) error { client := opts.NewDashboardClient(auth.OAuthClientID()) - if _, err := opts.Authenticate(opts.IO, client); err != nil { + accessToken, err := opts.Authenticate(opts.IO, client) + if err != nil { return err } - appID, err := opts.config.Profile().GetApplicationID() + appID, err := opts.resolveApplicationForAccount(client, accessToken) if err != nil { - app, selErr := opts.SelectApplication() - if selErr != nil { - return selErr - } - if app == nil { - // No application is available to scope to; the selection flow has - // already explained the situation to the user. - return nil - } - appID = app.ID + return err + } + if appID == "" { + // No application is available to scope to; the selection flow has + // already explained the situation to the user. + return nil } // The base URL is resolved from ALGOLIA_DASHBOARD_URL by the dashboard @@ -316,6 +336,74 @@ func openDashboardTarget(opts *OpenOptions, target dashboardTarget) error { return opts.Browser(url) } +func applicationInAccount(apps []dashboard.Application, appID string) bool { + for _, app := range apps { + if app.ID == appID { + return true + } + } + return false +} + +func (opts *OpenOptions) fetchAccountApplications( + client *dashboard.Client, + accessToken string, +) ([]dashboard.Application, error) { + listFn := opts.ListApplications + if listFn == nil { + listFn = func(c *dashboard.Client, token string) ([]dashboard.Application, error) { + return c.ListApplications(token) + } + } + + opts.IO.StartProgressIndicatorWithLabel("Fetching applications") + apps, err := listFn(client, accessToken) + opts.IO.StopProgressIndicator() + if err != nil { + newToken, reAuthErr := auth.ReauthenticateIfExpired(opts.IO, client, err) + if reAuthErr != nil { + return nil, reAuthErr + } + opts.IO.StartProgressIndicatorWithLabel("Fetching applications") + apps, err = listFn(client, newToken) + opts.IO.StopProgressIndicator() + if err != nil { + return nil, err + } + } + + return apps, nil +} + +// resolveApplicationForAccount returns an application ID that belongs to the +// signed-in account. The configured profile application is used only when it +// appears in the account's application list; otherwise the user is prompted to +// select one. +func (opts *OpenOptions) resolveApplicationForAccount( + client *dashboard.Client, + accessToken string, +) (string, error) { + apps, err := opts.fetchAccountApplications(client, accessToken) + if err != nil { + return "", err + } + + appID, err := opts.config.Profile().GetApplicationID() + if err == nil && applicationInAccount(apps, appID) { + return appID, nil + } + + app, selErr := opts.SelectApplication() + if selErr != nil { + return "", selErr + } + if app == nil { + return "", nil + } + + return app.ID, nil +} + // dashboardURL builds the dashboard URL for an application page. Application // pages are scoped via the /apps/{appID} path; account pages carry the // application in an applicationId query parameter. diff --git a/pkg/cmd/open/open_test.go b/pkg/cmd/open/open_test.go index dae93f01..dd281c72 100644 --- a/pkg/cmd/open/open_test.go +++ b/pkg/cmd/open/open_test.go @@ -68,6 +68,9 @@ func newTestOptions( NewDashboardClient: func(string) *dashboard.Client { return &dashboard.Client{DashboardURL: "https://dashboard.algolia.com"} }, + ListApplications: func(_ *dashboard.Client, _ string) ([]dashboard.Application, error) { + return []dashboard.Application{{ID: "APP123", Name: "Test"}}, nil + }, Browser: func(u string) error { *opened = u return nil @@ -111,7 +114,7 @@ func TestRunOpenCmd_ResourceShortcutWithAppID(t *testing.T) { err := runOpenCmd(opts) require.NoError(t, err) - assert.False(t, *authed, "status must not require sign-in") + assert.True(t, *authed, "app-scoped status validates the profile application against the signed-in account") assert.Equal(t, "https://staging.algolia.test/apps/APP123/monitoring/status", *opened) } @@ -195,6 +198,38 @@ func TestRunOpenCmd_DashboardTargetUsesConfiguredDashboardURL(t *testing.T) { ) } +func TestRunOpenCmd_DashboardTargetIgnoresStaleProfileApp(t *testing.T) { + t.Setenv("ALGOLIA_APPLICATION_ID", "") + + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + + cfg := test.NewConfigStubWithProfiles([]*config.Profile{ + {Name: "default", ApplicationID: "USER_A_APP", APIKey: "key", Default: true}, + }) + + selectCalled := false + opts, opened, _ := newTestOptions(io, cfg) + opts.Shortcut = "billing" + opts.ListApplications = func(_ *dashboard.Client, _ string) ([]dashboard.Application, error) { + return []dashboard.Application{{ID: "USER_B_APP", Name: "User B App"}}, nil + } + opts.SelectApplication = func() (*dashboard.Application, error) { + selectCalled = true + return &dashboard.Application{ID: "USER_B_APP", Name: "User B App"}, nil + } + + err := runOpenCmd(opts) + require.NoError(t, err) + assert.True(t, selectCalled, "stale profile application must trigger selection") + assert.Equal( + t, + "https://dashboard.algolia.com/account/billing/details?applicationId=USER_B_APP", + *opened, + ) +} + func TestRunOpenCmd_DashboardTargetSelectsAppWhenNoneConfigured(t *testing.T) { t.Setenv("ALGOLIA_APPLICATION_ID", "") @@ -225,7 +260,10 @@ func TestRunOpenCmd_Unsupported(t *testing.T) { err := runOpenCmd(opts) require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported open command") + assert.Contains(t, err.Error(), "unsupported open command, given: bogus") + assert.Contains(t, err.Error(), "Available shortcuts:") + assert.Contains(t, err.Error(), "billing") + assert.Contains(t, err.Error(), "docs") assert.False(t, *authed) assert.Empty(t, *opened) } @@ -329,7 +367,8 @@ func TestRunOpenCmd_UnsupportedWithJSONOutput(t *testing.T) { err := runOpenCmd(opts) require.Error(t, err) - assert.Contains(t, err.Error(), "unsupported open command") + assert.Contains(t, err.Error(), "unsupported open command, given: bogus") + assert.Contains(t, err.Error(), "Available shortcuts:") } func TestRunOpenCmd_InvalidOutputFormat(t *testing.T) {