Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import (
"github.com/slackapi/slack-cli/cmd/openformresponse"
"github.com/slackapi/slack-cli/cmd/platform"
"github.com/slackapi/slack-cli/cmd/project"
"github.com/slackapi/slack-cli/cmd/sandbox"
"github.com/slackapi/slack-cli/cmd/triggers"
"github.com/slackapi/slack-cli/cmd/upgrade"
versioncmd "github.com/slackapi/slack-cli/cmd/version"
Expand Down Expand Up @@ -175,6 +176,7 @@ func Init(ctx context.Context) (*cobra.Command, *shared.ClientFactory) {
openformresponse.NewCommand(clients),
platform.NewCommand(clients),
project.NewCommand(clients),
sandbox.NewCommand(clients),
triggers.NewCommand(clients),
upgrade.NewCommand(clients),
versioncmd.NewCommand(clients),
Expand Down
145 changes: 145 additions & 0 deletions cmd/sandbox/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright 2022-2026 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 sandbox

import (
"fmt"
"strings"
"time"

"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

type listFlags struct {
filter string
token string
}

var listCmdFlags listFlags

func NewListCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "list [flags]",
Short: "List your sandboxes",
Long: `List all of your active or archived sandboxes.`,
Example: style.ExampleCommandsf([]style.ExampleCommand{
{Command: "sandbox list", Meaning: "List your sandboxes"},
{Command: "sandbox list --filter active", Meaning: "List active sandboxes only"},
}),
PreRunE: func(cmd *cobra.Command, args []string) error {
return requireSandboxExperiment(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
return runListCommand(cmd, clients)
},
}

cmd.Flags().StringVar(&listCmdFlags.filter, "filter", "", "Filter by status: active, archived")
cmd.Flags().StringVar(&listCmdFlags.token, "token", "", "Service account token for CI/CD authentication")

return cmd
}

func runListCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
ctx := cmd.Context()

token, auth, err := getSandboxTokenAndAuth(ctx, clients, listCmdFlags.token)
if err != nil {
return err
}

fmt.Println()
err = printSandboxes(cmd, clients, token, auth)
if err != nil {
return err
}

return nil
}

func printSandboxes(cmd *cobra.Command, clients *shared.ClientFactory, token string, auth *types.SlackAuth) error {
ctx := cmd.Context()

sandboxes, err := clients.API().ListSandboxes(ctx, token, listCmdFlags.filter)
if err != nil {
return err
}

email := ""
if auth != nil && auth.UserID != "" {
if userInfo, err := clients.API().UsersInfo(ctx, token, auth.UserID); err == nil && userInfo.Profile.Email != "" {
email = userInfo.Profile.Email
}
}

section := style.TextSection{
Emoji: "beach_with_umbrella",
Text: " Developer Sandboxes",
}

if email != "" {
section.Secondary = []string{fmt.Sprintf("Owned by Slack developer account %s", email)}
}

clients.IO.PrintInfo(ctx, false, "%s", style.Sectionf(section))

if len(sandboxes) == 0 {
clients.IO.PrintInfo(ctx, false, "%s\n", style.Secondary("No sandboxes found. Create one with `slack sandbox create --name <name>`"))
return nil
}

timeFormat := "2006-01-02" // We only support the granularity of the day for now, rather than a more precise datetime
for _, s := range sandboxes {
cmd.Printf(" %s (%s)\n", style.Bold(s.SandboxName), s.SandboxTeamID)

if s.SandboxDomain != "" {
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("URL: https://%s.slack.com", s.SandboxDomain)))
}

if s.DateCreated > 0 {
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat))))
}

if s.Status != "" {
status := style.Secondary(fmt.Sprintf("Status: %s", strings.ToTitle(s.Status)))
if strings.EqualFold(s.Status, "archived") {
cmd.Printf(" %s %s\n", style.Emoji("warning"), status)
} else {
cmd.Printf(" %s\n", status)
}
}

if s.DateArchived > 0 {
archivedTime := time.Unix(s.DateArchived, 0).In(time.Local)
now := time.Now()
archivedDate := time.Date(archivedTime.Year(), archivedTime.Month(), archivedTime.Day(), 0, 0, 0, 0, time.Local)
todayDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.Local)
label := "Active until:"
if archivedDate.Before(todayDate) {
label = "Archived:"
}
cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("%s %s", label, archivedTime.Format(timeFormat))))
}

cmd.Println()
}

clients.IO.PrintInfo(ctx, false, "Learn more at %s", style.Secondary("https://docs.slack.dev/tools/developer-sandboxes"))

return nil
}
158 changes: 158 additions & 0 deletions cmd/sandbox/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright 2022-2026 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 sandbox

import (
"context"
"errors"
"testing"

"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/shared/types"
"github.com/slackapi/slack-cli/internal/slackcontext"
"github.com/slackapi/slack-cli/test/testutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)

func TestListCommand(t *testing.T) {
ctx := slackcontext.MockContext(context.Background())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()

// Enable sandboxes experiment
clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)

clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewListCommand(clients)
testutil.MockCmdIO(clients.IO, cmd)

// Use --token to bypass stored credentials; mock AuthWithToken
testToken := "xoxb-test-token"
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)

// Mock ListSandboxes to return empty list
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil)

cmd.SetArgs([]string{"--token", testToken})
err := cmd.ExecuteContext(ctx)
require.NoError(t, err)

clientsMock.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, testToken)
clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "")
assert.Contains(t, clientsMock.GetStdoutOutput(), "No sandboxes found")
}

func TestListCommand_withSandboxes(t *testing.T) {
ctx := slackcontext.MockContext(context.Background())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()

clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)

clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewListCommand(clients)
testutil.MockCmdIO(clients.IO, cmd)

testToken := "xoxb-test-token"
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)

sandboxes := []types.Sandbox{
{
SandboxTeamID: "T123",

Check failure on line 78 in cmd/sandbox/list_test.go

View workflow job for this annotation

GitHub Actions / Lints and Tests

File is not properly formatted (gofmt)
SandboxName: "my-sandbox",
SandboxDomain: "my-sandbox",
Status: "active",
DateCreated: 1700000000,
DateArchived: 0,
},
}
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil)

cmd.SetArgs([]string{"--token", testToken})
err := cmd.ExecuteContext(ctx)
require.NoError(t, err)

clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "")
assert.Contains(t, clientsMock.GetStdoutOutput(), "my-sandbox")
assert.Contains(t, clientsMock.GetStdoutOutput(), "T123")
assert.Contains(t, clientsMock.GetStdoutOutput(), "https://my-sandbox.slack.com")
}

func TestListCommand_withFilter(t *testing.T) {
ctx := slackcontext.MockContext(context.Background())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()

clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)

clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewListCommand(clients)
testutil.MockCmdIO(clients.IO, cmd)

testToken := "xoxb-test-token"
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "active").Return([]types.Sandbox{}, nil)

cmd.SetArgs([]string{"--token", testToken, "--filter", "active"})
err := cmd.ExecuteContext(ctx)
require.NoError(t, err)

clientsMock.API.AssertCalled(t, "ListSandboxes", mock.Anything, testToken, "active")
}

func TestListCommand_listError(t *testing.T) {
ctx := slackcontext.MockContext(context.Background())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()

clientsMock.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)}
clientsMock.Config.LoadExperiments(ctx, clientsMock.IO.PrintDebug)

clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewListCommand(clients)
testutil.MockCmdIO(clients.IO, cmd)

testToken := "xoxb-test-token"
clientsMock.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil)
clientsMock.API.On("ListSandboxes", mock.Anything, testToken, "").
Return([]types.Sandbox(nil), errors.New("api_error"))

cmd.SetArgs([]string{"--token", testToken})
err := cmd.ExecuteContext(ctx)
require.Error(t, err)
}

func TestListCommand_experimentRequired(t *testing.T) {
ctx := slackcontext.MockContext(context.Background())
clientsMock := shared.NewClientsMock()
clientsMock.AddDefaultMocks()

// Do NOT enable sandboxes experiment
clients := shared.NewClientFactory(clientsMock.MockClientFactory())
cmd := NewListCommand(clients)
testutil.MockCmdIO(clients.IO, cmd)

cmd.SetArgs([]string{"--token", "xoxb-test"})
err := cmd.ExecuteContext(ctx)
require.Error(t, err)
assert.Contains(t, err.Error(), "sandbox")
clientsMock.API.AssertNotCalled(t, "ListSandboxes", mock.Anything, mock.Anything, mock.Anything)
}
56 changes: 56 additions & 0 deletions cmd/sandbox/sandbox.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright 2022-2026 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 sandbox

import (
"github.com/slackapi/slack-cli/internal/experiment"
"github.com/slackapi/slack-cli/internal/shared"
"github.com/slackapi/slack-cli/internal/slackerror"
"github.com/slackapi/slack-cli/internal/style"
"github.com/spf13/cobra"
)

func NewCommand(clients *shared.ClientFactory) *cobra.Command {
cmd := &cobra.Command{
Use: "sandbox <subcommand> [flags] --experiment=sandboxes",
Short: "Manage your sandboxes",
Long: `Manage your Slack developer sandboxes without leaving your terminal.
Use the --team flag to select the authentication to use for these commands.

Prefer a UI? Head over to {{LinkText "https://api.slack.com/developer-program/sandboxes"}}

New to the Developer Program? Sign up at {{LinkText "https://api.slack.com/developer-program/join"}}`,
Example: style.ExampleCommandsf([]style.ExampleCommand{}),
PreRunE: func(cmd *cobra.Command, args []string) error {
return requireSandboxExperiment(clients)
},
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}

cmd.AddCommand(NewListCommand(clients))

return cmd
}

func requireSandboxExperiment(clients *shared.ClientFactory) error {
if !clients.Config.WithExperimentOn(experiment.Sandboxes) {
return slackerror.New(slackerror.ErrMissingExperiment).
WithMessage("%sThe sandbox management commands are under construction", style.Emoji("construction")).
WithRemediation("To try them out, just add the --experiment=sandboxes flag to your command!")
}
return nil
}
Loading
Loading