diff --git a/cmd/app/app.go b/cmd/app/app.go index 8d442f1e..6db78f47 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { {Command: "app install", Meaning: "Install a production app to a team"}, {Command: "app link", Meaning: "Link an existing app to the project"}, {Command: "app list", Meaning: "List all teams with the app installed"}, + {Command: "app settings", Meaning: "Open app settings in a web browser"}, {Command: "app uninstall", Meaning: "Uninstall an app from a team"}, {Command: "app delete", Meaning: "Delete an app and app info from a team"}, }), @@ -66,6 +67,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command { cmd.AddCommand(NewDeleteCommand(clients)) cmd.AddCommand(NewLinkCommand(clients)) cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewSettingsCommand(clients)) cmd.AddCommand(NewUninstallCommand(clients)) return cmd diff --git a/cmd/app/settings.go b/cmd/app/settings.go new file mode 100644 index 00000000..38da92c7 --- /dev/null +++ b/cmd/app/settings.go @@ -0,0 +1,122 @@ +// Copyright 2022-2025 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "fmt" + "net/url" + "strings" + + "github.com/slackapi/slack-cli/internal/cmdutil" + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +var settingsAppSelectPromptFunc = prompts.AppSelectPrompt + +func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "settings [flags]", + Short: "Open app settings for configurations", + Long: strings.Join([]string{ + "Open app settings to configure an application in a web browser.", + "", + "Discovering new features and customizing an app manifest can be done from this", + fmt.Sprintf("web interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()), + "", + "This command does not support apps deployed to Run on Slack infrastructure.", + }, "\n"), + Example: style.ExampleCommandsf([]style.ExampleCommand{ + { + Meaning: "Open app settings for a prompted app", + Command: "app settings", + }, + { + Meaning: "Open app settings for a specific app", + Command: "app settings --app A0123456789", + }, + }), + Args: cobra.MaximumNArgs(0), + PreRunE: func(cmd *cobra.Command, args []string) error { + return appSettingsCommandPreRunE(clients, cmd, args) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return appSettingsCommandRunE(clients, cmd, args) + }, + } + return cmd +} + +// appSettingsCommandPreRunE determines if the command can be run in a project +func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + err := cmdutil.IsValidProjectDirectory(clients) + if err != nil { + return err + } + // Allow the force flag to ignore hosted apps and try to open app settings + if clients.Config.ForceFlag { + return nil + } + err = cmdutil.IsSlackHostedProject(ctx, clients) + if err != nil { + if slackerror.Is(err, slackerror.ErrAppNotHosted) { + return nil + } else { + return err + } + } + return slackerror.New(slackerror.ErrAppHosted). + WithDetails(slackerror.ErrorDetails{ + { + Message: "App settings is not supported with Run on Slack infrastructure", + }, + }) +} + +// appSettingsCommandRunE opens app settings in a browser for the selected app +func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart) + + app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps) + if err != nil { + return err + } + host := clients.API().Host() + parsed, err := url.Parse(host) + if err != nil { + return err + } + parsed.Host = "api." + parsed.Host + settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID) + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "house", + Text: "App Settings", + Secondary: []string{ + settingsURL, + }, + })) + clients.Browser().OpenURL(settingsURL) + + clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL) + return nil +} diff --git a/cmd/app/settings_test.go b/cmd/app/settings_test.go new file mode 100644 index 00000000..d1203589 --- /dev/null +++ b/cmd/app/settings_test.go @@ -0,0 +1,199 @@ +// Copyright 2022-2025 Salesforce, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "testing" + + "github.com/slackapi/slack-cli/internal/app" + "github.com/slackapi/slack-cli/internal/config" + "github.com/slackapi/slack-cli/internal/prompts" + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/slacktrace" + "github.com/slackapi/slack-cli/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func Test_App_SettingsCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "requires a valid project directory": { + ExpectedError: slackerror.New(slackerror.ErrInvalidAppDirectory), + }, + "errors for rosi applications": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig.WorkingDirectory = "." + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On( + "GetManifestSource", + mock.Anything, + ).Return( + config.ManifestSourceLocal, + nil, + ) + cm.Config.ProjectConfig = projectConfigMock + manifestMock := &app.ManifestMockObject{} + manifestMock.On( + "GetManifestLocal", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted}, + }, + }, + nil, + ) + cm.AppClient.Manifest = manifestMock + }, + ExpectedError: slackerror.New(slackerror.ErrAppHosted), + }, + "opens a rosi application with the force flag": { + CmdArgs: []string{"--force"}, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig.WorkingDirectory = "." + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On( + "GetManifestSource", + mock.Anything, + ).Return( + config.ManifestSourceLocal, + nil, + ) + cm.Config.ProjectConfig = projectConfigMock + manifestMock := &app.ManifestMockObject{} + manifestMock.On( + "GetManifestLocal", + mock.Anything, + mock.Anything, + mock.Anything, + ).Return( + types.SlackYaml{ + AppManifest: types.AppManifest{ + Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted}, + }, + }, + nil, + ) + cm.AppClient.Manifest = manifestMock + appSelectMock := prompts.NewAppSelectMock() + appSelectMock.On( + "AppSelectPrompt", + ).Return( + prompts.SelectedApp{App: types.App{AppID: "A0101010101"}}, + nil, + ) + settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://api.slack.com/apps/A0101010101" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL}) + }, + }, + "requires an existing application": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig.WorkingDirectory = "." + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On( + "GetManifestSource", + mock.Anything, + ).Return( + config.ManifestSourceRemote, + nil, + ) + cm.Config.ProjectConfig = projectConfigMock + appSelectMock := prompts.NewAppSelectMock() + appSelectMock.On( + "AppSelectPrompt", + ).Return( + prompts.SelectedApp{}, + slackerror.New(slackerror.ErrInstallationRequired), + ) + settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt + }, + ExpectedError: slackerror.New(slackerror.ErrInstallationRequired), + }, + "opens the url to app settings of an app in production": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cf.SDKConfig.WorkingDirectory = "." + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On( + "GetManifestSource", + mock.Anything, + ).Return( + config.ManifestSourceRemote, + nil, + ) + cm.Config.ProjectConfig = projectConfigMock + appSelectMock := prompts.NewAppSelectMock() + appSelectMock.On( + "AppSelectPrompt", + ).Return( + prompts.SelectedApp{App: types.App{AppID: "A0123456789"}}, + nil, + ) + settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://api.slack.com/apps/A0123456789" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL}) + }, + }, + "opens the url to app settings of an app in development": { + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + host := "https://dev1234.slack.com" + cf.SDKConfig.WorkingDirectory = "." + projectConfigMock := config.NewProjectConfigMock() + projectConfigMock.On( + "GetManifestSource", + mock.Anything, + ).Return( + config.ManifestSourceRemote, + nil, + ) + cm.Config.ProjectConfig = projectConfigMock + appSelectMock := prompts.NewAppSelectMock() + appSelectMock.On( + "AppSelectPrompt", + ).Return( + prompts.SelectedApp{ + App: types.App{AppID: "A0123456789"}, + Auth: types.SlackAuth{APIHost: &host}, + }, + nil, + ) + settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt + cm.API.On("Host").Return(host) // SetHost is implemented in AppSelectPrompt + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + expectedURL := "https://api.dev1234.slack.com/apps/A0123456789" + cm.Browser.AssertCalled(t, "OpenURL", expectedURL) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything) + cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL}) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewSettingsCommand(cf) + }) +} diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a1061369..869e4c3e 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -42,6 +42,7 @@ const ( ErrAppExists = "app_add_exists" ErrAppFlagRequired = "app_flag_required" ErrAppFound = "app_found" + ErrAppHosted = "app_hosted" ErrAppInstall = "app_install_error" ErrAppManifestAccess = "app_manifest_access_error" ErrAppManifestCreate = "app_manifest_create_error" @@ -381,6 +382,11 @@ Otherwise start your app for local development with: %s`, Message: "An app was found", }, + ErrAppHosted: { + Code: ErrAppHosted, + Message: "App is configured for Run on Slack infrastructure", + }, + ErrAppInstall: { Code: ErrAppInstall, Message: "Couldn't install your app to a workspace", diff --git a/internal/slacktrace/slacktrace.go b/internal/slacktrace/slacktrace.go index 816b9218..cc7c777c 100644 --- a/internal/slacktrace/slacktrace.go +++ b/internal/slacktrace/slacktrace.go @@ -44,6 +44,8 @@ const ( AdminAppApprovalRequestRequired = "SLACK_TRACE_ADMIN_APPROVAL_REQUIRED" AppLinkStart = "SLACK_TRACE_APP_LINK_START" AppLinkSuccess = "SLACK_TRACE_APP_LINK_SUCCESS" + AppSettingsStart = "SLACK_TRACE_APP_SETTINGS_START" + AppSettingsSuccess = "SLACK_TRACE_APP_SETTINGS_SUCCESS" AuthListCount = "SLACK_TRACE_AUTH_LIST_COUNT" AuthListInfo = "SLACK_TRACE_AUTH_LIST_INFO" AuthListSuccess = "SLACK_TRACE_AUTH_LIST_SUCCESS"