Skip to content

Commit 9ab82a5

Browse files
authored
feat: list your developer sandboxes (#379)
1 parent d1f659d commit 9ab82a5

14 files changed

Lines changed: 614 additions & 57 deletions

File tree

cmd/app/link.go

Lines changed: 2 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ import (
1818
"context"
1919
"fmt"
2020
"path/filepath"
21-
"slices"
2221
"strings"
2322

2423
"github.com/slackapi/slack-cli/internal/cmdutil"
2524
"github.com/slackapi/slack-cli/internal/config"
2625
"github.com/slackapi/slack-cli/internal/iostreams"
2726
"github.com/slackapi/slack-cli/internal/pkg/apps"
27+
"github.com/slackapi/slack-cli/internal/prompts"
2828
"github.com/slackapi/slack-cli/internal/shared"
2929
"github.com/slackapi/slack-cli/internal/shared/types"
3030
"github.com/slackapi/slack-cli/internal/slackerror"
@@ -262,7 +262,7 @@ func LinkAppFooterSection(ctx context.Context, clients *shared.ClientFactory, ap
262262

263263
// promptExistingApp gathers details to represent app information
264264
func promptExistingApp(ctx context.Context, clients *shared.ClientFactory) (types.App, *types.SlackAuth, error) {
265-
slackAuth, err := promptTeamSlackAuth(ctx, clients)
265+
slackAuth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select the existing app team")
266266
if err != nil {
267267
return types.App{}, &types.SlackAuth{}, err
268268
}
@@ -291,61 +291,6 @@ func promptExistingApp(ctx context.Context, clients *shared.ClientFactory) (type
291291
return apps[0], slackAuth, nil
292292
}
293293

294-
// promptTeamSlackAuth retrieves an authenticated team from input
295-
func promptTeamSlackAuth(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) {
296-
allAuths, err := clients.Auth().Auths(ctx)
297-
if err != nil {
298-
return &types.SlackAuth{}, err
299-
}
300-
slices.SortFunc(allAuths, func(i, j types.SlackAuth) int {
301-
if i.TeamDomain == j.TeamDomain {
302-
return strings.Compare(i.TeamID, j.TeamID)
303-
}
304-
return strings.Compare(i.TeamDomain, j.TeamDomain)
305-
})
306-
var teamLabels []string
307-
for _, auth := range allAuths {
308-
teamLabels = append(
309-
teamLabels,
310-
style.TeamSelectLabel(auth.TeamDomain, auth.TeamID),
311-
)
312-
}
313-
selection, err := clients.IO.SelectPrompt(
314-
ctx,
315-
"Select the existing app team",
316-
teamLabels,
317-
iostreams.SelectPromptConfig{
318-
Required: true,
319-
Flag: clients.Config.Flags.Lookup("team"),
320-
},
321-
)
322-
if err != nil {
323-
return &types.SlackAuth{}, err
324-
}
325-
if selection.Prompt {
326-
clients.Auth().SetSelectedAuth(ctx, allAuths[selection.Index], clients.Config, clients.Os)
327-
return &allAuths[selection.Index], nil
328-
}
329-
teamMatch := false
330-
teamIndex := -1
331-
for ii, auth := range allAuths {
332-
if selection.Option == auth.TeamID || selection.Option == auth.TeamDomain {
333-
if teamMatch {
334-
return &types.SlackAuth{}, slackerror.New(slackerror.ErrMissingAppTeamID).
335-
WithMessage("The team cannot be determined by team domain").
336-
WithRemediation("Provide the team ID for the installed app")
337-
}
338-
teamMatch = true
339-
teamIndex = ii
340-
}
341-
}
342-
if !teamMatch {
343-
return &types.SlackAuth{}, slackerror.New(slackerror.ErrCredentialsNotFound)
344-
}
345-
clients.Auth().SetSelectedAuth(ctx, allAuths[teamIndex], clients.Config, clients.Os)
346-
return &allAuths[teamIndex], nil
347-
}
348-
349294
// promptAppID retrieves an app ID from user input
350295
func promptAppID(ctx context.Context, clients *shared.ClientFactory) (string, error) {
351296
if clients.Config.Flags.Lookup("app").Changed {

cmd/root.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import (
4040
"github.com/slackapi/slack-cli/cmd/openformresponse"
4141
"github.com/slackapi/slack-cli/cmd/platform"
4242
"github.com/slackapi/slack-cli/cmd/project"
43+
"github.com/slackapi/slack-cli/cmd/sandbox"
4344
"github.com/slackapi/slack-cli/cmd/triggers"
4445
"github.com/slackapi/slack-cli/cmd/upgrade"
4546
versioncmd "github.com/slackapi/slack-cli/cmd/version"
@@ -175,6 +176,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) {
175176
openformresponse.NewCommand(clients),
176177
platform.NewCommand(clients),
177178
project.NewCommand(clients),
179+
sandbox.NewCommand(clients),
178180
triggers.NewCommand(clients),
179181
upgrade.NewCommand(clients),
180182
versioncmd.NewCommand(clients),

cmd/sandbox/list.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// Copyright 2022-2026 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 sandbox
16+
17+
import (
18+
"fmt"
19+
"strings"
20+
"time"
21+
22+
"github.com/slackapi/slack-cli/internal/shared"
23+
"github.com/slackapi/slack-cli/internal/shared/types"
24+
"github.com/slackapi/slack-cli/internal/style"
25+
"github.com/spf13/cobra"
26+
)
27+
28+
type listFlags struct {
29+
status string
30+
}
31+
32+
var listCmdFlags listFlags
33+
34+
func NewListCommand(clients *shared.ClientFactory) *cobra.Command {
35+
cmd := &cobra.Command{
36+
Use: "list [flags]",
37+
Short: "List developer sandboxes",
38+
Long: strings.Join([]string{
39+
"List details about your developer sandboxes.",
40+
"",
41+
"The listed developer sandboxes belong to a developer program account",
42+
"that matches the email address of the authenticated user.",
43+
}, "\n"),
44+
Example: style.ExampleCommandsf([]style.ExampleCommand{
45+
{Command: "sandbox list", Meaning: "List developer sandboxes"},
46+
{Command: "sandbox list --status active", Meaning: "List active sandboxes only"},
47+
}),
48+
PreRunE: func(cmd *cobra.Command, args []string) error {
49+
return requireSandboxExperiment(clients)
50+
},
51+
RunE: func(cmd *cobra.Command, args []string) error {
52+
return runListCommand(cmd, clients)
53+
},
54+
}
55+
56+
cmd.Flags().StringVar(&listCmdFlags.status, "status", "", "Filter by status: active, archived")
57+
58+
return cmd
59+
}
60+
61+
func runListCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
62+
ctx := cmd.Context()
63+
64+
auth, err := getSandboxAuth(ctx, clients)
65+
if err != nil {
66+
return err
67+
}
68+
69+
clients.IO.PrintInfo(ctx, false, "")
70+
err = printSandboxes(cmd, clients, auth.Token, auth)
71+
if err != nil {
72+
return err
73+
}
74+
75+
return nil
76+
}
77+
78+
func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token string, auth *types.SlackAuth) error {
79+
ctx := cmd.Context()
80+
81+
sandboxes, err := clients.API().ListSandboxes(ctx, token, listCmdFlags.status)
82+
if err != nil {
83+
return err
84+
}
85+
86+
email := ""
87+
if auth != nil && auth.UserID != "" {
88+
if userInfo, err := clients.API().UsersInfo(ctx, token, auth.UserID); err == nil && userInfo.Profile.Email != "" {
89+
email = userInfo.Profile.Email
90+
}
91+
}
92+
93+
section := style.TextSection{
94+
Emoji: "beach_with_umbrella",
95+
Text: " Developer Sandboxes",
96+
}
97+
98+
// Some users' logins may not include the scope needed to access the email address from the `users.info` method, so it may not be set
99+
// Learn more: https://docs.slack.dev/reference/methods/users.info/#email-addresses
100+
if email != "" {
101+
section.Secondary = []string{fmt.Sprintf("Owned by Slack developer account %s", email)}
102+
}
103+
104+
clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(section))
105+
106+
if len(sandboxes) == 0 {
107+
clients.IO.PrintInfo(ctx, false, " %s\n", style.Secondary("No sandboxes found"))
108+
return nil
109+
}
110+
111+
timeFormat := "2006-01-02" // We only support the granularity of the day for now, rather than a more precise datetime
112+
for _, s := range sandboxes {
113+
clients.IO.PrintInfo(ctx, false, " %s (%s)", style.Bold(s.SandboxName), s.SandboxTeamID)
114+
115+
if s.SandboxDomain != "" {
116+
clients.IO.PrintInfo(ctx, false, " %s", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.SandboxDomain)))
117+
}
118+
119+
if s.Status != "" {
120+
status := style.Secondary(fmt.Sprintf("Status: %s", strings.ToTitle(s.Status)))
121+
if strings.EqualFold(s.Status, "archived") {
122+
clients.IO.PrintInfo(ctx, false, " %s", style.Styler().Red(status))
123+
} else {
124+
clients.IO.PrintInfo(ctx, false, " %s", style.Styler().Green(status))
125+
}
126+
}
127+
128+
if s.DateCreated > 0 {
129+
clients.IO.PrintInfo(ctx, false, " %s", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat))))
130+
}
131+
132+
if s.DateArchived > 0 {
133+
archivedTime := time.Unix(s.DateArchived, 0).In(time.Local)
134+
now := time.Now()
135+
archivedDate := time.Date(archivedTime.Year(), archivedTime.Month(), archivedTime.Day(), 0, 0, 0, 0, time.Local)
136+
todayDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
137+
label := "Expires:"
138+
if archivedDate.Before(todayDate) {
139+
label = "Archived:"
140+
}
141+
clients.IO.PrintInfo(ctx, false, " %s", style.Secondary(fmt.Sprintf("%s %s", label, archivedTime.Format(timeFormat))))
142+
}
143+
144+
clients.IO.PrintInfo(ctx, false, "")
145+
}
146+
147+
clients.IO.PrintInfo(ctx, false, "Learn more at %s", style.Secondary("https://docs.slack.dev/tools/developer-sandboxes"))
148+
149+
return nil
150+
}

cmd/sandbox/list_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2022-2026 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 sandbox
16+
17+
import (
18+
"context"
19+
"errors"
20+
"testing"
21+
22+
"github.com/slackapi/slack-cli/internal/experiment"
23+
"github.com/slackapi/slack-cli/internal/shared"
24+
"github.com/slackapi/slack-cli/internal/shared/types"
25+
"github.com/slackapi/slack-cli/test/testutil"
26+
"github.com/spf13/cobra"
27+
"github.com/stretchr/testify/mock"
28+
)
29+
30+
func TestListCommand(t *testing.T) {
31+
testutil.TableTestCommand(t, testutil.CommandTests{
32+
"empty list": {
33+
CmdArgs: []string{"--experiment=sandboxes", "--token", "xoxb-test-token"},
34+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
35+
testToken := "xoxb-test-token"
36+
cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
37+
cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
38+
cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
39+
cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil)
40+
cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil)
41+
42+
cm.AddDefaultMocks()
43+
cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
44+
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
45+
},
46+
ExpectedStdoutOutputs: []string{"No sandboxes found"},
47+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
48+
cm.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, "xoxb-test-token")
49+
cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "")
50+
},
51+
},
52+
"with sandboxes": {
53+
CmdArgs: []string{"--experiment=sandboxes", "--token", "xoxb-test-token"},
54+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
55+
testToken := "xoxb-test-token"
56+
cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
57+
cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
58+
cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
59+
sandboxes := []types.Sandbox{
60+
{
61+
SandboxTeamID: "T123",
62+
SandboxName: "my-sandbox",
63+
SandboxDomain: "my-sandbox",
64+
Status: "active",
65+
DateCreated: 1700000000,
66+
DateArchived: 0,
67+
},
68+
}
69+
cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil)
70+
cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil)
71+
72+
cm.AddDefaultMocks()
73+
cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
74+
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
75+
},
76+
ExpectedStdoutOutputs: []string{"my-sandbox", "T123", "https://my-sandbox.slack.com"},
77+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
78+
cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "")
79+
},
80+
},
81+
"with status": {
82+
CmdArgs: []string{"--experiment=sandboxes", "--token", "xoxb-test-token", "--status", "active"},
83+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
84+
testToken := "xoxb-test-token"
85+
cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
86+
cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
87+
cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
88+
cm.API.On("ListSandboxes", mock.Anything, testToken, "active").Return([]types.Sandbox{}, nil)
89+
cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil)
90+
91+
cm.AddDefaultMocks()
92+
cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
93+
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
94+
},
95+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
96+
cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "active")
97+
},
98+
},
99+
"list error": {
100+
CmdArgs: []string{"--experiment=sandboxes", "--token", "xoxb-test-token"},
101+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
102+
testToken := "xoxb-test-token"
103+
cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
104+
cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
105+
cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
106+
cm.API.On("ListSandboxes", mock.Anything, testToken, "").
107+
Return([]types.Sandbox(nil), errors.New("api_error"))
108+
109+
cm.AddDefaultMocks()
110+
cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
111+
cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug)
112+
},
113+
ExpectedErrorStrings: []string{"api_error"},
114+
},
115+
"experiment required": {
116+
CmdArgs: []string{},
117+
Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) {
118+
cm.AddDefaultMocks()
119+
// Do NOT enable sandboxes experiment
120+
},
121+
ExpectedErrorStrings: []string{"sandbox"},
122+
ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) {
123+
cm.API.AssertNotCalled(t, "ListSandboxes", mock.Anything, mock.Anything, mock.Anything)
124+
},
125+
},
126+
}, func(cf *shared.ClientFactory) *cobra.Command {
127+
return NewListCommand(cf)
128+
})
129+
}

0 commit comments

Comments
 (0)