Skip to content

Commit f768cc2

Browse files
srtaalejzimegmwbrooks
authored
feat: add 'app unlink' command (#266)
* feat: app unlink command * fix: linter errors cmd/app/unlink.go * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * Update cmd/app/unlink.go Co-authored-by: Eden Zimbelman <[email protected]> * fix: bug in unlink logic * Update cmd/app/unlink.go * Update cmd/app/unlink.go --------- Co-authored-by: Eden Zimbelman <[email protected]> Co-authored-by: Michael Brooks <[email protected]>
1 parent ab67955 commit f768cc2

File tree

4 files changed

+279
-0
lines changed

4 files changed

+279
-0
lines changed

cmd/app/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
3636
{Command: "app list", Meaning: "List all teams with the app installed"},
3737
{Command: "app settings", Meaning: "Open app settings in a web browser"},
3838
{Command: "app uninstall", Meaning: "Uninstall an app from a team"},
39+
{Command: "app unlink", Meaning: "Remove a linked app from the project"},
3940
{Command: "app delete", Meaning: "Delete an app and app info from a team"},
4041
}),
4142
Args: cobra.NoArgs,
@@ -69,6 +70,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
6970
cmd.AddCommand(NewListCommand(clients))
7071
cmd.AddCommand(NewSettingsCommand(clients))
7172
cmd.AddCommand(NewUninstallCommand(clients))
73+
cmd.AddCommand(NewUnlinkCommand(clients))
7274

7375
return cmd
7476
}

cmd/app/unlink.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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+
"fmt"
20+
"strings"
21+
22+
"github.com/slackapi/slack-cli/internal/cmdutil"
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/slacktrace"
27+
"github.com/slackapi/slack-cli/internal/style"
28+
"github.com/spf13/cobra"
29+
)
30+
31+
// Handle to function used for testing
32+
var unlinkAppSelectPromptFunc = prompts.AppSelectPrompt
33+
34+
// NewUnlinkCommand returns a new Cobra command for unlinking apps
35+
func NewUnlinkCommand(clients *shared.ClientFactory) *cobra.Command {
36+
var unlinkedApp types.App // capture app for PostRunE
37+
38+
cmd := &cobra.Command{
39+
Use: "unlink",
40+
Short: "Remove a linked app from the project",
41+
Long: strings.Join([]string{
42+
"Unlink removes an existing app from the project.",
43+
"",
44+
"This command removes a saved app ID from the files of a project without deleting",
45+
"the app from Slack.",
46+
}, "\n"),
47+
Example: style.ExampleCommandsf([]style.ExampleCommand{
48+
{
49+
Meaning: "Remove an existing app from the project",
50+
Command: "app unlink",
51+
},
52+
{
53+
Meaning: "Remove a specific app without using prompts",
54+
Command: "app unlink --app A0123456789",
55+
},
56+
}),
57+
PreRunE: func(cmd *cobra.Command, args []string) error {
58+
return cmdutil.IsValidProjectDirectory(clients)
59+
},
60+
RunE: func(cmd *cobra.Command, args []string) error {
61+
ctx := cmd.Context()
62+
63+
app, err := UnlinkCommandRunE(ctx, clients, cmd, args)
64+
if err != nil {
65+
return err
66+
}
67+
if app.AppID == "" { // user canceled
68+
return nil
69+
}
70+
unlinkedApp = app // stored for PostRunE
71+
return printUnlinkSuccess(ctx, clients, app)
72+
},
73+
PostRunE: func(cmd *cobra.Command, args []string) error {
74+
ctx := cmd.Context()
75+
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkSuccess, unlinkedApp.AppID)
76+
return nil
77+
},
78+
}
79+
return cmd
80+
}
81+
82+
// UnlinkCommandRunE executes the unlink command, prints output, and returns any errors.
83+
func UnlinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command, args []string) (types.App, error) {
84+
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkStart)
85+
86+
// Get the app selection from the flag or prompt
87+
selection, err := unlinkAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps)
88+
if err != nil {
89+
return types.App{}, err
90+
}
91+
92+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
93+
Emoji: "unlock",
94+
Text: "App Unlink",
95+
Secondary: []string{
96+
fmt.Sprintf("App (%s) will be removed from this project", selection.App.AppID),
97+
"The app will not be deleted from Slack",
98+
fmt.Sprintf("You can re-link it later with %s", style.Commandf("app link", false)),
99+
},
100+
}))
101+
102+
// Confirm with user unless --force flag is used
103+
if !clients.Config.ForceFlag {
104+
proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to unlink this app?", false)
105+
if err != nil {
106+
return types.App{}, err
107+
}
108+
if !proceed {
109+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
110+
Emoji: "thumbs_up",
111+
Text: "Your app will not be unlinked",
112+
}))
113+
return types.App{}, nil
114+
}
115+
}
116+
117+
// Remove the app from the project
118+
app, err := clients.AppClient().Remove(ctx, selection.App)
119+
if err != nil {
120+
return types.App{}, err
121+
}
122+
123+
return app, nil
124+
}
125+
126+
// printUnlinkSuccess displays success message after unlinking
127+
func printUnlinkSuccess(ctx context.Context, clients *shared.ClientFactory, app types.App) error {
128+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
129+
Emoji: "unlock",
130+
Text: "App Unlink",
131+
Secondary: []string{
132+
fmt.Sprintf("Removed app %s from project", app.AppID),
133+
fmt.Sprintf("Team: %s (%s)", app.TeamDomain, app.TeamID),
134+
},
135+
}))
136+
return nil
137+
}

cmd/app/unlink_test.go

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
"fmt"
20+
"testing"
21+
22+
"github.com/slackapi/slack-cli/internal/app"
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/test/testutil"
27+
"github.com/spf13/cobra"
28+
"github.com/stretchr/testify/mock"
29+
)
30+
31+
func TestAppsUnlinkCommand(t *testing.T) {
32+
testutil.TableTestCommand(t, testutil.CommandTests{
33+
"happy path; unlink the deployed app": {
34+
CmdArgs: []string{},
35+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
36+
prepareCommonUnlinkMocks(t, cf, cm)
37+
// Mock App Selection
38+
appSelectMock := prompts.NewAppSelectMock()
39+
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
40+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
41+
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
42+
App: fakeDeployedApp,
43+
}, nil)
44+
// Mock unlink confirmation prompt
45+
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
46+
// Mock AppClient calls
47+
appClientMock := &app.AppClientMock{}
48+
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeDeployedApp, nil)
49+
appClientMock.On("CleanUp").Return()
50+
cf.AppClient().AppClientInterface = appClientMock
51+
},
52+
ExpectedStdoutOutputs: []string{
53+
fmt.Sprintf("Removed app %s from project", fakeDeployedApp.AppID),
54+
fmt.Sprintf("Team: %s", fakeDeployedApp.TeamDomain),
55+
},
56+
},
57+
"happy path; unlink the local app": {
58+
CmdArgs: []string{},
59+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
60+
prepareCommonUnlinkMocks(t, cf, cm)
61+
// Mock App Selection
62+
appSelectMock := prompts.NewAppSelectMock()
63+
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
64+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
65+
Auth: types.SlackAuth{TeamDomain: fakeLocalApp.TeamDomain},
66+
App: fakeLocalApp,
67+
}, nil)
68+
// Mock unlink confirmation prompt
69+
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
70+
// Mock AppClient calls
71+
appClientMock := &app.AppClientMock{}
72+
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeLocalApp, nil)
73+
appClientMock.On("CleanUp").Return()
74+
cf.AppClient().AppClientInterface = appClientMock
75+
},
76+
ExpectedStdoutOutputs: []string{
77+
fmt.Sprintf("Removed app %s from project", fakeLocalApp.AppID),
78+
fmt.Sprintf("Team: %s", fakeLocalApp.TeamDomain),
79+
},
80+
},
81+
"sad path; unlinking the deployed app fails": {
82+
CmdArgs: []string{},
83+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
84+
prepareCommonUnlinkMocks(t, cf, cm)
85+
// Mock App Selection
86+
appSelectMock := prompts.NewAppSelectMock()
87+
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
88+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
89+
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
90+
App: fakeDeployedApp,
91+
}, nil)
92+
// Mock unlink confirmation prompt
93+
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
94+
// Mock AppClient calls - return error
95+
appClientMock := &app.AppClientMock{}
96+
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(types.App{}, fmt.Errorf("failed to remove app from project"))
97+
cf.AppClient().AppClientInterface = appClientMock
98+
},
99+
ExpectedError: fmt.Errorf("failed to remove app from project"),
100+
},
101+
"user cancels unlink": {
102+
CmdArgs: []string{},
103+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
104+
prepareCommonUnlinkMocks(t, cf, cm)
105+
// Mock App Selection
106+
appSelectMock := prompts.NewAppSelectMock()
107+
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
108+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
109+
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
110+
App: fakeDeployedApp,
111+
}, nil)
112+
// Mock unlink confirmation prompt - user says no
113+
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(false, nil)
114+
},
115+
ExpectedStdoutOutputs: []string{
116+
"Your app will not be unlinked",
117+
},
118+
},
119+
"errors if app selection fails": {
120+
CmdArgs: []string{},
121+
ExpectedError: fmt.Errorf("failed to select app"),
122+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
123+
prepareCommonUnlinkMocks(t, cf, cm)
124+
appSelectMock := prompts.NewAppSelectMock()
125+
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
126+
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{}, fmt.Errorf("failed to select app"))
127+
},
128+
},
129+
}, func(cf *shared.ClientFactory) *cobra.Command {
130+
cmd := NewUnlinkCommand(cf)
131+
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }
132+
return cmd
133+
})
134+
}
135+
136+
func prepareCommonUnlinkMocks(t *testing.T, cf *shared.ClientFactory, cm *shared.ClientsMock) {
137+
cm.AddDefaultMocks()
138+
}

internal/slacktrace/slacktrace.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ const (
4646
AppLinkSuccess = "SLACK_TRACE_APP_LINK_SUCCESS"
4747
AppSettingsStart = "SLACK_TRACE_APP_SETTINGS_START"
4848
AppSettingsSuccess = "SLACK_TRACE_APP_SETTINGS_SUCCESS"
49+
AppUnlinkStart = "SLACK_TRACE_APP_UNLINK_START"
50+
AppUnlinkSuccess = "SLACK_TRACE_APP_UNLINK_SUCCESS"
4951
AuthListCount = "SLACK_TRACE_AUTH_LIST_COUNT"
5052
AuthListInfo = "SLACK_TRACE_AUTH_LIST_INFO"
5153
AuthListSuccess = "SLACK_TRACE_AUTH_LIST_SUCCESS"

0 commit comments

Comments
 (0)