Skip to content
Merged
2 changes: 2 additions & 0 deletions cmd/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
{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 unlink", Meaning: "Remove a linked app from the project"},
{Command: "app delete", Meaning: "Delete an app and app info from a team"},
}),
Args: cobra.NoArgs,
Expand Down Expand Up @@ -69,6 +70,7 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd.AddCommand(NewListCommand(clients))
cmd.AddCommand(NewSettingsCommand(clients))
cmd.AddCommand(NewUninstallCommand(clients))
cmd.AddCommand(NewUnlinkCommand(clients))

return cmd
}
137 changes: 137 additions & 0 deletions cmd/app/unlink.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// 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"
"fmt"
"strings"

"github.com/slackapi/slack-cli/internal/cmdutil"
"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/slacktrace"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

// Handle to function used for testing
var unlinkAppSelectPromptFunc = prompts.AppSelectPrompt

// NewUnlinkCommand returns a new Cobra command for unlinking apps
func NewUnlinkCommand(clients *shared.ClientFactory) *cobra.Command {
var unlinkedApp types.App // capture app for PostRunE

cmd := &cobra.Command{
Use: "unlink",
Short: "Remove a linked app from the project",
Long: strings.Join([]string{
"Unlink removes an existing app from the project.",
"",
"This command removes a saved app ID from the files of a project without deleting",
"the app from Slack.",
}, "\n"),
Example: style.ExampleCommandsf([]style.ExampleCommand{
{
Meaning: "Remove an existing app from the project",
Command: "app unlink",
},
{
Meaning: "Remove a specific app without using prompts",
Command: "app unlink --app A0123456789",
},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
return cmdutil.IsValidProjectDirectory(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

app, err := UnlinkCommandRunE(ctx, clients, cmd, args)
if err != nil {
return err
}
if app.AppID == "" { // user canceled
return nil
}
unlinkedApp = app // stored for PostRunE
return printUnlinkSuccess(ctx, clients, app)
},
PostRunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkSuccess, unlinkedApp.AppID)
return nil
},
}
return cmd
}

// UnlinkCommandRunE executes the unlink command, prints output, and returns any errors.
func UnlinkCommandRunE(ctx context.Context, clients *shared.ClientFactory, cmd *cobra.Command, args []string) (types.App, error) {
clients.IO.PrintTrace(ctx, slacktrace.AppUnlinkStart)

// Get the app selection from the flag or prompt
selection, err := unlinkAppSelectPromptFunc(ctx, clients, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps)
if err != nil {
return types.App{}, err
}

clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "unlock",
Text: "App Unlink",
Secondary: []string{
fmt.Sprintf("App (%s) will be removed from this project", selection.App.AppID),
"The app will not be deleted from Slack",
fmt.Sprintf("You can re-link it later with %s", style.Commandf("app link", false)),
},
}))

// Confirm with user unless --force flag is used
if !clients.Config.ForceFlag {
proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to unlink this app?", false)
if err != nil {
return types.App{}, err
}
if !proceed {
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "thumbs_up",
Text: "Your app will not be unlinked",
}))
return types.App{}, nil
}
Comment on lines +108 to +114

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚧 issue: The following outputs appear after selecting "no" for me:

? Are you sure you want to unlink this app? No

👍 Your app will not be unlinked


🔓 App Unlinked
   Removed app  from project
   Team:

👁️‍🗨️ suggestion: Let's inline as much as possible to be within UnlinkCommandRunE to avoid jumping between functions!

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

✅ This is now resolved:

Image

}

// Remove the app from the project
app, err := clients.AppClient().Remove(ctx, selection.App)
if err != nil {
return types.App{}, err
}

return app, nil
}

// printUnlinkSuccess displays success message after unlinking
func printUnlinkSuccess(ctx context.Context, clients *shared.ClientFactory, app types.App) error {
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
Emoji: "unlock",
Text: "App Unlink",
Secondary: []string{
fmt.Sprintf("Removed app %s from project", app.AppID),
fmt.Sprintf("Team: %s (%s)", app.TeamDomain, app.TeamID),
},
}))
return nil
}
138 changes: 138 additions & 0 deletions cmd/app/unlink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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"
"fmt"
"testing"

"github.com/slackapi/slack-cli/internal/app"
"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/test/testutil"
"github.com/spf13/cobra"
"github.com/stretchr/testify/mock"
)

func TestAppsUnlinkCommand(t *testing.T) {
testutil.TableTestCommand(t, testutil.CommandTests{
"happy path; unlink the deployed app": {
CmdArgs: []string{},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
prepareCommonUnlinkMocks(t, cf, cm)
// Mock App Selection
appSelectMock := prompts.NewAppSelectMock()
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
App: fakeDeployedApp,
}, nil)
// Mock unlink confirmation prompt
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
// Mock AppClient calls
appClientMock := &app.AppClientMock{}
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeDeployedApp, nil)
appClientMock.On("CleanUp").Return()
cf.AppClient().AppClientInterface = appClientMock
},
ExpectedStdoutOutputs: []string{
fmt.Sprintf("Removed app %s from project", fakeDeployedApp.AppID),
fmt.Sprintf("Team: %s", fakeDeployedApp.TeamDomain),
},
},
"happy path; unlink the local app": {
CmdArgs: []string{},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
prepareCommonUnlinkMocks(t, cf, cm)
// Mock App Selection
appSelectMock := prompts.NewAppSelectMock()
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
Auth: types.SlackAuth{TeamDomain: fakeLocalApp.TeamDomain},
App: fakeLocalApp,
}, nil)
// Mock unlink confirmation prompt
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
// Mock AppClient calls
appClientMock := &app.AppClientMock{}
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(fakeLocalApp, nil)
appClientMock.On("CleanUp").Return()
cf.AppClient().AppClientInterface = appClientMock
},
ExpectedStdoutOutputs: []string{
fmt.Sprintf("Removed app %s from project", fakeLocalApp.AppID),
fmt.Sprintf("Team: %s", fakeLocalApp.TeamDomain),
},
},
"sad path; unlinking the deployed app fails": {
CmdArgs: []string{},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
prepareCommonUnlinkMocks(t, cf, cm)
// Mock App Selection
appSelectMock := prompts.NewAppSelectMock()
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
App: fakeDeployedApp,
}, nil)
// Mock unlink confirmation prompt
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(true, nil)
// Mock AppClient calls - return error
appClientMock := &app.AppClientMock{}
appClientMock.On("Remove", mock.Anything, mock.Anything).Return(types.App{}, fmt.Errorf("failed to remove app from project"))
cf.AppClient().AppClientInterface = appClientMock
},
ExpectedError: fmt.Errorf("failed to remove app from project"),
},
"user cancels unlink": {
CmdArgs: []string{},
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
prepareCommonUnlinkMocks(t, cf, cm)
// Mock App Selection
appSelectMock := prompts.NewAppSelectMock()
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{
Auth: types.SlackAuth{TeamDomain: fakeDeployedApp.TeamDomain},
App: fakeDeployedApp,
}, nil)
// Mock unlink confirmation prompt - user says no
cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to unlink this app?", mock.Anything).Return(false, nil)
},
ExpectedStdoutOutputs: []string{
"Your app will not be unlinked",
},
},
"errors if app selection fails": {
CmdArgs: []string{},
ExpectedError: fmt.Errorf("failed to select app"),
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
prepareCommonUnlinkMocks(t, cf, cm)
appSelectMock := prompts.NewAppSelectMock()
unlinkAppSelectPromptFunc = appSelectMock.AppSelectPrompt
appSelectMock.On("AppSelectPrompt", mock.Anything, mock.Anything, prompts.ShowAllEnvironments, prompts.ShowInstalledAndUninstalledApps).Return(prompts.SelectedApp{}, fmt.Errorf("failed to select app"))
},
},
}, func(cf *shared.ClientFactory) *cobra.Command {
cmd := NewUnlinkCommand(cf)
cmd.PreRunE = func(cmd *cobra.Command, args []string) error { return nil }
return cmd
})
}

func prepareCommonUnlinkMocks(t *testing.T, cf *shared.ClientFactory, cm *shared.ClientsMock) {
cm.AddDefaultMocks()
}
2 changes: 2 additions & 0 deletions internal/slacktrace/slacktrace.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ const (
AppLinkSuccess = "SLACK_TRACE_APP_LINK_SUCCESS"
AppSettingsStart = "SLACK_TRACE_APP_SETTINGS_START"
AppSettingsSuccess = "SLACK_TRACE_APP_SETTINGS_SUCCESS"
AppUnlinkStart = "SLACK_TRACE_APP_UNLINK_START"
AppUnlinkSuccess = "SLACK_TRACE_APP_UNLINK_SUCCESS"
AuthListCount = "SLACK_TRACE_AUTH_LIST_COUNT"
AuthListInfo = "SLACK_TRACE_AUTH_LIST_INFO"
AuthListSuccess = "SLACK_TRACE_AUTH_LIST_SUCCESS"
Expand Down
Loading