Skip to content

Commit 71aed03

Browse files
committed
feat: app unlink command
1 parent e4721af commit 71aed03

File tree

4 files changed

+280
-0
lines changed

4 files changed

+280
-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: 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+
21+
"github.com/slackapi/slack-cli/internal/cmdutil"
22+
"github.com/slackapi/slack-cli/internal/iostreams"
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+
cmd := &cobra.Command{
37+
Use: "unlink",
38+
Short: "Remove linked app from the project",
39+
Long: "Unlink a previously linked app from the project",
40+
Example: style.ExampleCommandsf([]style.ExampleCommand{
41+
{
42+
Meaning: "Remove an existing app from the project",
43+
Command: "app unlink",
44+
},
45+
{
46+
Meaning: "Remove a specific app without using prompts",
47+
Command: "app unlink --team T0123456789 --app A0123456789 --environment deployed",
48+
49+
},
50+
}),
51+
52+
PreRunE: func(cmd *cobra.Command, args []string) error {
53+
clients.Config.SetFlags(cmd)
54+
return cmdutil.IsValidProjectDirectory(clients)
55+
},
56+
RunE: func(cmd *cobra.Command, args []string) error {
57+
ctx := cmd.Context()
58+
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkStart)
59+
60+
app, err := UnlinkCommandRunE(ctx, clients, cmd, args)
61+
if err != nil {
62+
return err
63+
}
64+
return printUnlinkSuccess(ctx, clients, app)
65+
},
66+
PostRunE: func(cmd *cobra.Command, args []string) error {
67+
ctx := cmd.Context()
68+
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkSuccess)
69+
return nil
70+
},
71+
}
72+
return cmd
73+
}
74+
75+
// UnlinkCommandRunE executes the unlink command, prints output, and returns any errors.
76+
func UnlinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command, args []string) (types.App, error) {
77+
// Get the app selection from the flag or prompt
78+
selection, err := unlinkAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps)
79+
if err != nil {
80+
return types.App{}, err
81+
}
82+
83+
// Confirm with user unless --force flag is used
84+
if !clients.Config.ForceFlag {
85+
proceed, err := confirmUnlink(ctx, clients.IO, selection)
86+
if err != nil {
87+
return types.App{}, err
88+
}
89+
if !proceed {
90+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
91+
Emoji: "thumbs_up",
92+
Text: "Your app will not be unlinked",
93+
}))
94+
return types.App{}, nil
95+
}
96+
}
97+
98+
// Remove the app from the project
99+
app, err := clients.AppClient().Remove(ctx, selection.App)
100+
if err != nil {
101+
return types.App{}, err
102+
}
103+
104+
// Clean up empty files
105+
clients.AppClient().CleanUp()
106+
107+
return app, nil
108+
}
109+
110+
// confirmUnlink prompts the user to confirm unlinking the app
111+
func confirmUnlink(ctx context.Context, IO iostreams.IOStreamer, selection prompts.SelectedApp) (bool, error) {
112+
IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
113+
Emoji: "warning",
114+
Text: "Confirm Unlink",
115+
Secondary: []string{
116+
fmt.Sprintf("App (%s) will be removed from this project", selection.App.AppID),
117+
fmt.Sprintf("Team: %s", selection.Auth.TeamDomain),
118+
"The app will not be deleted from Slack",
119+
"You can re-link it later with 'slack app link'",
120+
},
121+
}))
122+
123+
proceed, err := IO.ConfirmPrompt(ctx, "Are you sure you want to unlink this app?", false)
124+
return proceed, err
125+
}
126+
127+
// printUnlinkSuccess displays success message after unlinking
128+
func printUnlinkSuccess(ctx context.Context, clients *shared.ClientFactory, app types.App) error {
129+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
130+
Emoji: "white_check_mark",
131+
Text: "App Unlinked",
132+
Secondary: []string{
133+
fmt.Sprintf("Removed app %s from project", app.AppID),
134+
fmt.Sprintf("Team: %s", app.TeamDomain),
135+
},
136+
}))
137+
return nil
138+
}

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)