Skip to content
Merged
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
2 changes: 2 additions & 0 deletions cmd/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Foreseeing a future of alphabetically ordered examples prefixed with app 🔮

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As much as I appreicate alphabetics I was thinking these examples were ordered with expected steps in mind?

To me this tells an interesting story:

  1. install
  2. link
  3. list
  4. settings
  5. uninstall
  6. delete

Which is almost in alphabetics I realize now! So perhaps following "install" with "delete" isn't so wild 👾

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhhh, that's alright as well. For now, the order flows well and it's easy to see where unlink & update would land.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When unlink lands we should revisit this. Background thoughts are now nudging me towards preferring the more standard order 😳

{Command: "app uninstall", Meaning: "Uninstall an app from a team"},
{Command: "app delete", Meaning: "Delete an app and app info from a team"},
}),
Expand Down Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions cmd/app/settings.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: [Non-Blocker] I'd like us to use this new command as a way to nudge out best practices for maintainability and testability. While we use the xyzFunc pattern for testability, it always feels like a hack.

I think the better approach would be dependency injection: NewSettingsCommand(clients, prompts.AppSelectPrompt).

But, how do we design this to scale for all commands and xyzFunc workarounds?

Maybe we use clients? This would remove the global swap-a-roo and isolate it to the instance of clients.

// clients.go
clients.AppSelectPrompt = prompts.AppSelectPrompt

// clients_mock.go
clients.AppSelectPrompt = appSelectMock.AppSelectPrompt

I think this is out-of-scope of this PR, but perhaps we can follow-up with some approach in a future PR? We can take some time to see if it helps address other xyzFunc use-cases as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mwbrooks I'm so interested in this as well 🧠 ✨

I considered mocking the calls used in AppSelectPrompt to avoid this pattern altogether, but found that this adds so much overhead and perhaps mixes concerns...

Using clients might be a decent approach! It seems like we're settling on patterns of having identical function signatures within a command:

RunE: func(cmd *cobra.Command, args []string) error {
	return appSettingsCommandRunE(clients, cmd, args)
},

But we've discussed avoiding clients for sake of more obvious usage of packages and dependencies. I'm wondering if within the command handler clients might be alright to keep, but for all other code following it should be avoided?

I'll hold off on making changes here so we can keep these patterns consistent for now, but I want to know what you think and I'm happy to follow up with changes we do decide on 🤖

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree, good call on not adding more complexity in this PR. Let's discuss it offline to figure out what we want to try out!


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)
Comment on lines +104 to +109
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Very nice work 👏🏻


clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "house",
Text: "App Settings",
Secondary: []string{
settingsURL,
},
Comment on lines +114 to +116
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: What do you think about adding output explaining the action being taken (opening the web browser)?

Suggested change
Secondary: []string{
settingsURL,
},
Secondary: []string{
"Opening default web browser:",
settingsURL,
},

☝🏻 You might have better wording than that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 I might have preference against this since erroring cases might cause confusion that the standalone link saves?

The balance of text also feels lopsided but I don't think this is significant reason against this:

🏠 App Settings
   Opening default web browser:
   https://api.slack.com/apps/A08SPJ83H4L

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 It does make me think --no-browser might be a flag to later support with the default behavior though!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me. Also --no-browser would be an appropriate future flag, since activity --browser already exists to open the activity in the browser.

}))
clients.Browser().OpenURL(settingsURL)

clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: heh, you read my mind to add settingsURL 😉

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setups of PrintTrace continue to impress me! 👾

I'm hoping soon we can equip all commands with at least a simple "start" and "success" with follow ups in @slack/cli-test but for now I'm also so glad we can print a success message with additional detail 📚 ✨

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like a reasonable PR to add a "start" and "success" to each command and land the equivalent definitions in @slack/cli-test.

return nil
}
199 changes: 199 additions & 0 deletions cmd/app/settings_test.go
Original file line number Diff line number Diff line change
@@ -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": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: Nice, I tested this manually and I'm happy to see you already added a test!

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)
})
}
6 changes: 6 additions & 0 deletions internal/slackerror/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const (
ErrAppExists = "app_add_exists"
ErrAppFlagRequired = "app_flag_required"
ErrAppFound = "app_found"
ErrAppHosted = "app_hosted"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: I can see this being a useful error for us in the future!

ErrAppInstall = "app_install_error"
ErrAppManifestAccess = "app_manifest_access_error"
ErrAppManifestCreate = "app_manifest_create_error"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions internal/slacktrace/slacktrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading