Skip to content

Commit 1977c44

Browse files
zimegmwbrooks
andauthored
feat: add 'app settings' command to open app settings webpage for a selected app (#92)
Co-authored-by: Michael Brooks <[email protected]>
1 parent 4485405 commit 1977c44

File tree

5 files changed

+331
-0
lines changed

5 files changed

+331
-0
lines changed

cmd/app/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
3434
{Command: "app install", Meaning: "Install a production app to a team"},
3535
{Command: "app link", Meaning: "Link an existing app to the project"},
3636
{Command: "app list", Meaning: "List all teams with the app installed"},
37+
{Command: "app settings", Meaning: "Open app settings in a web browser"},
3738
{Command: "app uninstall", Meaning: "Uninstall an app from a team"},
3839
{Command: "app delete", Meaning: "Delete an app and app info from a team"},
3940
}),
@@ -66,6 +67,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
6667
cmd.AddCommand(NewDeleteCommand(clients))
6768
cmd.AddCommand(NewLinkCommand(clients))
6869
cmd.AddCommand(NewListCommand(clients))
70+
cmd.AddCommand(NewSettingsCommand(clients))
6971
cmd.AddCommand(NewUninstallCommand(clients))
7072

7173
return cmd

cmd/app/settings.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2022-2025 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package app
16+
17+
import (
18+
"fmt"
19+
"net/url"
20+
"strings"
21+
22+
"github.com/slackapi/slack-cli/internal/cmdutil"
23+
"github.com/slackapi/slack-cli/internal/config"
24+
"github.com/slackapi/slack-cli/internal/prompts"
25+
"github.com/slackapi/slack-cli/internal/shared"
26+
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/slacktrace"
28+
"github.com/slackapi/slack-cli/internal/style"
29+
"github.com/spf13/cobra"
30+
)
31+
32+
var settingsAppSelectPromptFunc = prompts.AppSelectPrompt
33+
34+
func NewSettingsCommand(clients *shared.ClientFactory) *cobra.Command {
35+
cmd := &cobra.Command{
36+
Use: "settings [flags]",
37+
Short: "Open app settings for configurations",
38+
Long: strings.Join([]string{
39+
"Open app settings to configure an application in a web browser.",
40+
"",
41+
"Discovering new features and customizing an app manifest can be done from this",
42+
fmt.Sprintf("web interface for apps with a \"%s\" manifest source.", config.ManifestSourceRemote.String()),
43+
"",
44+
"This command does not support apps deployed to Run on Slack infrastructure.",
45+
}, "\n"),
46+
Example: style.ExampleCommandsf([]style.ExampleCommand{
47+
{
48+
Meaning: "Open app settings for a prompted app",
49+
Command: "app settings",
50+
},
51+
{
52+
Meaning: "Open app settings for a specific app",
53+
Command: "app settings --app A0123456789",
54+
},
55+
}),
56+
Args: cobra.MaximumNArgs(0),
57+
PreRunE: func(cmd *cobra.Command, args []string) error {
58+
return appSettingsCommandPreRunE(clients, cmd, args)
59+
},
60+
RunE: func(cmd *cobra.Command, args []string) error {
61+
return appSettingsCommandRunE(clients, cmd, args)
62+
},
63+
}
64+
return cmd
65+
}
66+
67+
// appSettingsCommandPreRunE determines if the command can be run in a project
68+
func appSettingsCommandPreRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
69+
ctx := cmd.Context()
70+
err := cmdutil.IsValidProjectDirectory(clients)
71+
if err != nil {
72+
return err
73+
}
74+
// Allow the force flag to ignore hosted apps and try to open app settings
75+
if clients.Config.ForceFlag {
76+
return nil
77+
}
78+
err = cmdutil.IsSlackHostedProject(ctx, clients)
79+
if err != nil {
80+
if slackerror.Is(err, slackerror.ErrAppNotHosted) {
81+
return nil
82+
} else {
83+
return err
84+
}
85+
}
86+
return slackerror.New(slackerror.ErrAppHosted).
87+
WithDetails(slackerror.ErrorDetails{
88+
{
89+
Message: "App settings is not supported with Run on Slack infrastructure",
90+
},
91+
})
92+
}
93+
94+
// appSettingsCommandRunE opens app settings in a browser for the selected app
95+
func appSettingsCommandRunE(clients *shared.ClientFactory, cmd *cobra.Command, args []string) error {
96+
ctx := cmd.Context()
97+
clients.IO.PrintTrace(ctx, slacktrace.AppSettingsStart)
98+
99+
app, err := settingsAppSelectPromptFunc(ctx, clients, prompts.ShowInstalledAndUninstalledApps)
100+
if err != nil {
101+
return err
102+
}
103+
host := clients.API().Host()
104+
parsed, err := url.Parse(host)
105+
if err != nil {
106+
return err
107+
}
108+
parsed.Host = "api." + parsed.Host
109+
settingsURL := fmt.Sprintf("%s/apps/%s", parsed.String(), app.App.AppID)
110+
111+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
112+
Emoji: "house",
113+
Text: "App Settings",
114+
Secondary: []string{
115+
settingsURL,
116+
},
117+
}))
118+
clients.Browser().OpenURL(settingsURL)
119+
120+
clients.IO.PrintTrace(ctx, slacktrace.AppSettingsSuccess, settingsURL)
121+
return nil
122+
}

cmd/app/settings_test.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
// Copyright 2022-2025 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package app
16+
17+
import (
18+
"context"
19+
"testing"
20+
21+
"github.com/slackapi/slack-cli/internal/app"
22+
"github.com/slackapi/slack-cli/internal/config"
23+
"github.com/slackapi/slack-cli/internal/prompts"
24+
"github.com/slackapi/slack-cli/internal/shared"
25+
"github.com/slackapi/slack-cli/internal/shared/types"
26+
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/slacktrace"
28+
"github.com/slackapi/slack-cli/test/testutil"
29+
"github.com/spf13/cobra"
30+
"github.com/stretchr/testify/mock"
31+
)
32+
33+
func Test_App_SettingsCommand(t *testing.T) {
34+
testutil.TableTestCommand(t, testutil.CommandTests{
35+
"requires a valid project directory": {
36+
ExpectedError: slackerror.New(slackerror.ErrInvalidAppDirectory),
37+
},
38+
"errors for rosi applications": {
39+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
40+
cf.SDKConfig.WorkingDirectory = "."
41+
projectConfigMock := config.NewProjectConfigMock()
42+
projectConfigMock.On(
43+
"GetManifestSource",
44+
mock.Anything,
45+
).Return(
46+
config.ManifestSourceLocal,
47+
nil,
48+
)
49+
cm.Config.ProjectConfig = projectConfigMock
50+
manifestMock := &app.ManifestMockObject{}
51+
manifestMock.On(
52+
"GetManifestLocal",
53+
mock.Anything,
54+
mock.Anything,
55+
mock.Anything,
56+
).Return(
57+
types.SlackYaml{
58+
AppManifest: types.AppManifest{
59+
Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted},
60+
},
61+
},
62+
nil,
63+
)
64+
cm.AppClient.Manifest = manifestMock
65+
},
66+
ExpectedError: slackerror.New(slackerror.ErrAppHosted),
67+
},
68+
"opens a rosi application with the force flag": {
69+
CmdArgs: []string{"--force"},
70+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
71+
cf.SDKConfig.WorkingDirectory = "."
72+
projectConfigMock := config.NewProjectConfigMock()
73+
projectConfigMock.On(
74+
"GetManifestSource",
75+
mock.Anything,
76+
).Return(
77+
config.ManifestSourceLocal,
78+
nil,
79+
)
80+
cm.Config.ProjectConfig = projectConfigMock
81+
manifestMock := &app.ManifestMockObject{}
82+
manifestMock.On(
83+
"GetManifestLocal",
84+
mock.Anything,
85+
mock.Anything,
86+
mock.Anything,
87+
).Return(
88+
types.SlackYaml{
89+
AppManifest: types.AppManifest{
90+
Settings: &types.AppSettings{FunctionRuntime: types.SlackHosted},
91+
},
92+
},
93+
nil,
94+
)
95+
cm.AppClient.Manifest = manifestMock
96+
appSelectMock := prompts.NewAppSelectMock()
97+
appSelectMock.On(
98+
"AppSelectPrompt",
99+
).Return(
100+
prompts.SelectedApp{App: types.App{AppID: "A0101010101"}},
101+
nil,
102+
)
103+
settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
104+
},
105+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
106+
expectedURL := "https://api.slack.com/apps/A0101010101"
107+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
108+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
109+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
110+
},
111+
},
112+
"requires an existing application": {
113+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
114+
cf.SDKConfig.WorkingDirectory = "."
115+
projectConfigMock := config.NewProjectConfigMock()
116+
projectConfigMock.On(
117+
"GetManifestSource",
118+
mock.Anything,
119+
).Return(
120+
config.ManifestSourceRemote,
121+
nil,
122+
)
123+
cm.Config.ProjectConfig = projectConfigMock
124+
appSelectMock := prompts.NewAppSelectMock()
125+
appSelectMock.On(
126+
"AppSelectPrompt",
127+
).Return(
128+
prompts.SelectedApp{},
129+
slackerror.New(slackerror.ErrInstallationRequired),
130+
)
131+
settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
132+
},
133+
ExpectedError: slackerror.New(slackerror.ErrInstallationRequired),
134+
},
135+
"opens the url to app settings of an app in production": {
136+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
137+
cf.SDKConfig.WorkingDirectory = "."
138+
projectConfigMock := config.NewProjectConfigMock()
139+
projectConfigMock.On(
140+
"GetManifestSource",
141+
mock.Anything,
142+
).Return(
143+
config.ManifestSourceRemote,
144+
nil,
145+
)
146+
cm.Config.ProjectConfig = projectConfigMock
147+
appSelectMock := prompts.NewAppSelectMock()
148+
appSelectMock.On(
149+
"AppSelectPrompt",
150+
).Return(
151+
prompts.SelectedApp{App: types.App{AppID: "A0123456789"}},
152+
nil,
153+
)
154+
settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
155+
},
156+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
157+
expectedURL := "https://api.slack.com/apps/A0123456789"
158+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
159+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
160+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
161+
},
162+
},
163+
"opens the url to app settings of an app in development": {
164+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
165+
host := "https://dev1234.slack.com"
166+
cf.SDKConfig.WorkingDirectory = "."
167+
projectConfigMock := config.NewProjectConfigMock()
168+
projectConfigMock.On(
169+
"GetManifestSource",
170+
mock.Anything,
171+
).Return(
172+
config.ManifestSourceRemote,
173+
nil,
174+
)
175+
cm.Config.ProjectConfig = projectConfigMock
176+
appSelectMock := prompts.NewAppSelectMock()
177+
appSelectMock.On(
178+
"AppSelectPrompt",
179+
).Return(
180+
prompts.SelectedApp{
181+
App: types.App{AppID: "A0123456789"},
182+
Auth: types.SlackAuth{APIHost: &host},
183+
},
184+
nil,
185+
)
186+
settingsAppSelectPromptFunc = appSelectMock.AppSelectPrompt
187+
cm.API.On("Host").Return(host) // SetHost is implemented in AppSelectPrompt
188+
},
189+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
190+
expectedURL := "https://api.dev1234.slack.com/apps/A0123456789"
191+
cm.Browser.AssertCalled(t, "OpenURL", expectedURL)
192+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsStart, mock.Anything)
193+
cm.IO.AssertCalled(t, "PrintTrace", mock.Anything, slacktrace.AppSettingsSuccess, []string{expectedURL})
194+
},
195+
},
196+
}, func(cf *shared.ClientFactory) *cobra.Command {
197+
return NewSettingsCommand(cf)
198+
})
199+
}

internal/slackerror/errors.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
ErrAppExists = "app_add_exists"
4343
ErrAppFlagRequired = "app_flag_required"
4444
ErrAppFound = "app_found"
45+
ErrAppHosted = "app_hosted"
4546
ErrAppInstall = "app_install_error"
4647
ErrAppManifestAccess = "app_manifest_access_error"
4748
ErrAppManifestCreate = "app_manifest_create_error"
@@ -381,6 +382,11 @@ Otherwise start your app for local development with: %s`,
381382
Message: "An app was found",
382383
},
383384

385+
ErrAppHosted: {
386+
Code: ErrAppHosted,
387+
Message: "App is configured for Run on Slack infrastructure",
388+
},
389+
384390
ErrAppInstall: {
385391
Code: ErrAppInstall,
386392
Message: "Couldn't install your app to a workspace",

internal/slacktrace/slacktrace.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ const (
4444
AdminAppApprovalRequestRequired = "SLACK_TRACE_ADMIN_APPROVAL_REQUIRED"
4545
AppLinkStart = "SLACK_TRACE_APP_LINK_START"
4646
AppLinkSuccess = "SLACK_TRACE_APP_LINK_SUCCESS"
47+
AppSettingsStart = "SLACK_TRACE_APP_SETTINGS_START"
48+
AppSettingsSuccess = "SLACK_TRACE_APP_SETTINGS_SUCCESS"
4749
AuthListCount = "SLACK_TRACE_AUTH_LIST_COUNT"
4850
AuthListInfo = "SLACK_TRACE_AUTH_LIST_INFO"
4951
AuthListSuccess = "SLACK_TRACE_AUTH_LIST_SUCCESS"

0 commit comments

Comments
 (0)