Skip to content

Commit 23b254c

Browse files
committed
update
1 parent 0e522b5 commit 23b254c

5 files changed

Lines changed: 296 additions & 34 deletions

File tree

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,15 @@ package sandbox
1616

1717
import (
1818
"context"
19+
"fmt"
20+
"slices"
1921
"strings"
2022

23+
"github.com/slackapi/slack-cli/internal/iostreams"
2124
"github.com/slackapi/slack-cli/internal/shared"
2225
"github.com/slackapi/slack-cli/internal/shared/types"
2326
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/style"
2428
)
2529

2630
// getSandboxToken returns the token to use for sandbox API operations.
@@ -30,22 +34,26 @@ func getSandboxToken(ctx context.Context, clients *shared.ClientFactory, tokenFl
3034
return token, err
3135
}
3236

33-
// getSandboxTokenAndAuth returns the token and auth used for sandbox API operations.
34-
// When --token is provided, auth is resolved via AuthWithToken (may have limited fields).
35-
// Otherwise auth comes from stored credentials.
37+
// getSandboxTokenAndAuth returns the auth used for sandbox management.
38+
// The --token flag will be used if present, otherwise we prompt the user to select a team to use for authentication.
3639
func getSandboxTokenAndAuth(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, *types.SlackAuth, error) {
40+
var auth *types.SlackAuth
3741
if tokenFlag != "" {
38-
auth, err := clients.Auth().AuthWithToken(ctx, tokenFlag)
42+
a, err := clients.Auth().AuthWithToken(ctx, tokenFlag)
3943
if err != nil {
4044
return "", nil, err
4145
}
42-
return tokenFlag, &auth, nil
46+
auth = &a
47+
} else {
48+
a, err := resolveAuthForSandbox(ctx, clients)
49+
if err != nil {
50+
return "", nil, err
51+
}
52+
auth = a
4353
}
4454

45-
auth, err := resolveAuthForSandbox(ctx, clients)
46-
if err != nil {
47-
return "", nil, err
48-
}
55+
clients.Config.APIHostResolved = clients.Auth().ResolveAPIHost(ctx, clients.Config.APIHostFlag, auth)
56+
clients.Config.LogstashHostResolved = clients.Auth().ResolveLogstashHost(ctx, clients.Config.APIHostResolved, clients.CLIVersion)
4957

5058
return auth.Token, auth, nil
5159
}
@@ -82,26 +90,55 @@ func resolveAuthForSandbox(ctx context.Context, clients *shared.ClientFactory) (
8290
}
8391
}
8492
return nil, slackerror.New(slackerror.ErrTeamNotFound).
85-
WithMessage("No auth found for team: " + clients.Config.TeamFlag).
93+
WithMessage("No auth found for team: %s", clients.Config.TeamFlag).
8694
WithRemediation("Run 'slack auth list' to see your authorized workspaces")
8795
}
8896

89-
// Use first auth
90-
return &auths[0], nil
91-
}
92-
93-
// parseLabels parses a comma-separated key=value string into a map
94-
func parseLabels(labelsStr string) map[string]string {
95-
if labelsStr == "" {
96-
return nil
97+
// Multiple auths: prompt user to select team; single auth: use it directly
98+
if len(auths) == 1 {
99+
return &auths[0], nil
97100
}
98-
99-
labels := make(map[string]string)
100-
for _, pair := range strings.Split(labelsStr, ",") {
101-
kv := strings.SplitN(strings.TrimSpace(pair), "=", 2)
102-
if len(kv) == 2 {
103-
labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
101+
type authOption struct {
102+
auth types.SlackAuth
103+
label string
104+
}
105+
options := make([]authOption, 0, len(auths))
106+
for _, a := range auths {
107+
options = append(options, authOption{
108+
auth: a,
109+
label: fmt.Sprintf("%s %s", a.TeamDomain, style.Secondary(a.TeamID)),
110+
})
111+
}
112+
slices.SortFunc(options, func(a, b authOption) int {
113+
if c := strings.Compare(a.auth.TeamDomain, b.auth.TeamDomain); c != 0 {
114+
return c
104115
}
116+
return strings.Compare(a.auth.TeamID, b.auth.TeamID)
117+
})
118+
labels := make([]string, 0, len(options))
119+
for _, opt := range options {
120+
labels = append(labels, opt.label)
121+
}
122+
selection, err := clients.IO.SelectPrompt(ctx, "Select a team for authentication", labels, iostreams.SelectPromptConfig{
123+
Flag: clients.Config.Flags.Lookup("team"),
124+
Required: true,
125+
})
126+
if err != nil {
127+
return nil, err
128+
}
129+
switch {
130+
case selection.Flag:
131+
for _, opt := range options {
132+
if opt.auth.TeamID == selection.Option || opt.auth.TeamDomain == selection.Option {
133+
return &opt.auth, nil
134+
}
135+
}
136+
return nil, slackerror.New(slackerror.ErrTeamNotFound).
137+
WithMessage("No auth found for team: %s", selection.Option).
138+
WithRemediation("Run 'slack auth list' to see your authorized workspaces")
139+
case selection.Prompt:
140+
return &options[selection.Index].auth, nil
141+
default:
142+
return nil, slackerror.New(slackerror.ErrInvalidAuth)
105143
}
106-
return labels
107144
}

cmd/sandbox/list.go

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func NewListCommand(clients *shared.ClientFactory) *cobra.Command {
3636
cmd := &cobra.Command{
3737
Use: "list [flags]",
3838
Short: "List your sandboxes",
39-
Long: `List all of your active or archived sandboxes.`,
39+
Long: `List details of your sandboxes`,
4040
Example: style.ExampleCommandsf([]style.ExampleCommand{
4141
{Command: "sandbox list", Meaning: "List your sandboxes"},
4242
{Command: "sandbox list --filter active", Meaning: "List active sandboxes only"},
@@ -91,6 +91,7 @@ func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token str
9191
Emoji: "beach_with_umbrella",
9292
Text: " Developer Sandboxes",
9393
}
94+
9495
if email != "" {
9596
section.Secondary = []string{fmt.Sprintf("Owned by Slack developer account %s", email)}
9697
}
@@ -102,28 +103,43 @@ func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token str
102103
return nil
103104
}
104105

105-
timeFormat := "2006-01-02 15:04"
106+
timeFormat := "2006-01-02" // We only support the granularity of the day for now, rather than a more precise datetime
106107
for _, s := range sandboxes {
107108
cmd.Printf(" %s (%s)\n", style.Bold(s.SandboxName), s.SandboxTeamID)
109+
108110
if s.SandboxDomain != "" {
109111
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.SandboxDomain)))
110112
}
113+
114+
if s.DateCreated > 0 {
115+
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat))))
116+
}
117+
111118
if s.Status != "" {
112119
status := style.Secondary(fmt.Sprintf("Status: %s", strings.ToTitle(s.Status)))
113120
if strings.EqualFold(s.Status, "archived") {
114121
cmd.Printf(" %s %s\n", style.Emoji("warning"), status)
115122
} else {
116-
cmd.Printf(" %s\n", status)
123+
cmd.Printf(" %s%s\n", style.Emoji("green_circle"), status)
117124
}
118125
}
119-
if s.DateCreated > 0 {
120-
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat))))
121-
}
126+
122127
if s.DateArchived > 0 {
123-
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Archived: %s", time.Unix(s.DateArchived, 0).Format(timeFormat))))
128+
archivedTime := time.Unix(s.DateArchived, 0).In(time.Local)
129+
now := time.Now()
130+
archivedDate := time.Date(archivedTime.Year(), archivedTime.Month(), archivedTime.Day(), 0, 0, 0, 0, time.Local)
131+
todayDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
132+
label := "Active until:"
133+
if archivedDate.Before(todayDate) {
134+
label = "Archived:"
135+
}
136+
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("%s %s", label, archivedTime.Format(timeFormat))))
124137
}
138+
125139
cmd.Println()
126140
}
127141

142+
clients.IO.PrintInfo(ctx, false, "Learn more at %s", style.Secondary("https://docs.slack.dev/tools/developer-sandboxes"))
143+
128144
return nil
129145
}

cmd/sandbox/list_test.go

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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/internal/slackcontext"
26+
"github.com/slackapi/slack-cli/test/testutil"
27+
"github.com/stretchr/testify/assert"
28+
"github.com/stretchr/testify/mock"
29+
"github.com/stretchr/testify/require"
30+
)
31+
32+
func TestListCommand(t *testing.T) {
33+
ctx := slackcontext.MockContext(context.Background())
34+
clientsMock := shared.NewClientsMock()
35+
clientsMock.AddDefaultMocks()
36+
37+
// Enable sandboxes experiment
38+
clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
39+
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)
40+
41+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
42+
cmd := NewListCommand(clients)
43+
testutil.MockCmdIO(clients.IO, cmd)
44+
45+
// Use --token to bypass stored credentials; mock AuthWithToken
46+
testToken := "xoxb-test-token"
47+
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
48+
clientsMock.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
49+
clientsMock.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
50+
51+
// Mock ListSandboxes to return empty list
52+
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil)
53+
54+
cmd.SetArgs([]string{"--token", testToken})
55+
err := cmd.ExecuteContext(ctx)
56+
require.NoError(t, err)
57+
58+
clientsMock.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, testToken)
59+
clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "")
60+
assert.Contains(t, clientsMock.GetStdoutOutput(), "No sandboxes found")
61+
}
62+
63+
func TestListCommand_withSandboxes(t *testing.T) {
64+
ctx := slackcontext.MockContext(context.Background())
65+
clientsMock := shared.NewClientsMock()
66+
clientsMock.AddDefaultMocks()
67+
68+
clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
69+
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)
70+
71+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
72+
cmd := NewListCommand(clients)
73+
testutil.MockCmdIO(clients.IO, cmd)
74+
75+
testToken := "xoxb-test-token"
76+
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
77+
clientsMock.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
78+
clientsMock.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
79+
80+
sandboxes := []types.Sandbox{
81+
{
82+
SandboxTeamID: "T123",
83+
SandboxName: "my-sandbox",
84+
SandboxDomain: "my-sandbox",
85+
Status: "active",
86+
DateCreated: 1700000000,
87+
DateArchived: 0,
88+
},
89+
}
90+
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil)
91+
92+
cmd.SetArgs([]string{"--token", testToken})
93+
err := cmd.ExecuteContext(ctx)
94+
require.NoError(t, err)
95+
96+
clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "")
97+
assert.Contains(t, clientsMock.GetStdoutOutput(), "my-sandbox")
98+
assert.Contains(t, clientsMock.GetStdoutOutput(), "T123")
99+
assert.Contains(t, clientsMock.GetStdoutOutput(), "https://my-sandbox.slack.com")
100+
}
101+
102+
func TestListCommand_withFilter(t *testing.T) {
103+
ctx := slackcontext.MockContext(context.Background())
104+
clientsMock := shared.NewClientsMock()
105+
clientsMock.AddDefaultMocks()
106+
107+
clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
108+
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)
109+
110+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
111+
cmd := NewListCommand(clients)
112+
testutil.MockCmdIO(clients.IO, cmd)
113+
114+
testToken := "xoxb-test-token"
115+
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
116+
clientsMock.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
117+
clientsMock.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
118+
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "active").Return([]types.Sandbox{}, nil)
119+
120+
cmd.SetArgs([]string{"--token", testToken, "--filter", "active"})
121+
err := cmd.ExecuteContext(ctx)
122+
require.NoError(t, err)
123+
124+
clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "active")
125+
}
126+
127+
func TestListCommand_listError(t *testing.T) {
128+
ctx := slackcontext.MockContext(context.Background())
129+
clientsMock := shared.NewClientsMock()
130+
clientsMock.AddDefaultMocks()
131+
132+
clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
133+
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)
134+
135+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
136+
cmd := NewListCommand(clients)
137+
testutil.MockCmdIO(clients.IO, cmd)
138+
139+
testToken := "xoxb-test-token"
140+
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
141+
clientsMock.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com")
142+
clientsMock.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli")
143+
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").
144+
Return([]types.Sandbox(nil), errors.New("api_error"))
145+
146+
cmd.SetArgs([]string{"--token", testToken})
147+
err := cmd.ExecuteContext(ctx)
148+
require.Error(t, err)
149+
}
150+
151+
func TestListCommand_experimentRequired(t *testing.T) {
152+
ctx := slackcontext.MockContext(context.Background())
153+
clientsMock := shared.NewClientsMock()
154+
clientsMock.AddDefaultMocks()
155+
156+
// Do NOT enable sandboxes experiment
157+
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
158+
cmd := NewListCommand(clients)
159+
testutil.MockCmdIO(clients.IO, cmd)
160+
161+
cmd.SetArgs([]string{"--token", "xoxb-test"})
162+
err := cmd.ExecuteContext(ctx)
163+
require.Error(t, err)
164+
assert.Contains(t, err.Error(), "sandbox")
165+
clientsMock.API.AssertNotCalled(t, "ListSandboxes", mock.Anything, mock.Anything, mock.Anything)
166+
}

cmd/sandbox/sandbox.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ func NewCommand(clients *shared.ClientFactory) *cobra.Command {
2929
Long: `Manage your Slack developer sandboxes without leaving your terminal.
3030
Use the --team flag to select the authentication to use for these commands.
3131
32-
Prefer a UI? Head over to https://api.slack.com/developer-program/sandboxes
32+
Prefer a UI? Head over to {{LinkText "https://api.slack.com/developer-program/sandboxes"}}
3333
34-
New to the Developer Program? Sign up at https://api.slack.com/developer-program/join`,
34+
New to the Developer Program? Sign up at {{LinkText "https://api.slack.com/developer-program/join"}}`,
3535
Example: style.ExampleCommandsf([]style.ExampleCommand{}),
3636
PreRunE: func(cmd *cobra.Command, args []string) error {
3737
return requireSandboxExperiment(clients)

0 commit comments

Comments
 (0)