From c0b11c58a93bbcff50397d9893c64ec172b45617 Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Wed, 25 Feb 2026 21:55:16 -0500 Subject: [PATCH 1/3] Sandbox management commands --- cmd/app/delete.go | 2 +- cmd/root.go | 2 + cmd/sandbox/create.go | 209 ++++++++++++++++++++++++++++++ cmd/sandbox/delete.go | 114 ++++++++++++++++ cmd/sandbox/list.go | 129 ++++++++++++++++++ cmd/sandbox/sandbox.go | 58 +++++++++ cmd/sandbox/token.go | 107 +++++++++++++++ internal/api/api_mock.go | 17 +++ internal/api/sandbox.go | 173 +++++++++++++++++++++++++ internal/api/types.go | 1 + internal/experiment/experiment.go | 4 + internal/shared/types/sandbox.go | 47 +++++++ internal/shared/types/user.go | 1 + internal/slackerror/errors.go | 8 +- 14 files changed, 870 insertions(+), 2 deletions(-) create mode 100644 cmd/sandbox/create.go create mode 100644 cmd/sandbox/delete.go create mode 100644 cmd/sandbox/list.go create mode 100644 cmd/sandbox/sandbox.go create mode 100644 cmd/sandbox/token.go create mode 100644 internal/api/sandbox.go create mode 100644 internal/shared/types/sandbox.go diff --git a/cmd/app/delete.go b/cmd/app/delete.go index 2ae6ae43..27c62981 100644 --- a/cmd/app/delete.go +++ b/cmd/app/delete.go @@ -128,7 +128,7 @@ func newDeleteLogger(clients *shared.ClientFactory, cmd *cobra.Command, envName func confirmDeletion(ctx context.Context, IO iostreams.IOStreamer, app prompts.SelectedApp) (bool, error) { IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", - Text: style.Bold("Danger zone"), + Text: style.Bold(" Danger zone"), Secondary: []string{ fmt.Sprintf("App (%s) will be permanently deleted", app.App.AppID), "All triggers, workflows, and functions will be deleted", diff --git a/cmd/root.go b/cmd/root.go index 7b78e246..70880d68 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -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" @@ -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), diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go new file mode 100644 index 00000000..7aea6286 --- /dev/null +++ b/cmd/sandbox/create.go @@ -0,0 +1,209 @@ +// 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 ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" + "github.com/slackapi/slack-cli/internal/style" + "github.com/spf13/cobra" +) + +type createFlags struct { + name string + domain string + password string + locale string + owningOrgID string + template string + eventCode string + ttl string + autoLogin bool + output string + token string +} + +var createCmdFlags createFlags + +func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [flags]", + Short: "Create a new sandbox", + Long: `Create a new Slack developer sandbox. + +Provisions a new sandbox. Domain is derived from org name if --domain is not provided.`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox create --name test-box", Meaning: "Create a sandbox named test-box"}, + {Command: "sandbox create --name test-box --password mypass --owning-org-id E12345", Meaning: "Create a sandbox with login password and owning org"}, + {Command: "sandbox create --name test-box --domain test-box --ttl 24h --output json", Meaning: "Create an ephemeral sandbox for CI/CD with JSON output"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreateCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox") + cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain (e.g., pizzaknifefight). If not provided, derived from org name") + cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox") + cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") + cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") + cmd.Flags().StringVar(&createCmdFlags.ttl, "ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)") + cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text") + cmd.Flags().StringVar(&createCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + + cmd.MarkFlagRequired("name") + cmd.MarkFlagRequired("domain") + cmd.MarkFlagRequired("password") + + return cmd +} + +func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + token, err := getSandboxToken(ctx, clients, createCmdFlags.token) + if err != nil { + return err + } + + domain := createCmdFlags.domain + if domain == "" { + domain = slugFromsandboxName(createCmdFlags.name) + } + + archiveDate, err := ttlToArchiveDate(createCmdFlags.ttl) + if err != nil { + return err + } + + result, err := clients.API().CreateSandbox(ctx, token, + createCmdFlags.name, + domain, + createCmdFlags.password, + createCmdFlags.locale, + createCmdFlags.owningOrgID, + createCmdFlags.template, + createCmdFlags.eventCode, + archiveDate, + ) + if err != nil { + return err + } + + switch createCmdFlags.output { + case "json": + encoder := json.NewEncoder(clients.IO.WriteOut()) + encoder.SetIndent("", " ") + if err := encoder.Encode(result); err != nil { + return err + } + default: + printCreateSuccess(cmd, clients, result) + } + + if createCmdFlags.autoLogin && result.URL != "" { + clients.Browser().OpenURL(result.URL) + } + + return nil +} + +const maxTTL = 180 * 24 * time.Hour // 6 months + +// ttlToArchiveDate parses a TTL string (e.g., "24h", "1d", "7d") and returns the Unix epoch +// when the sandbox will be archived. Returns 0 if ttl is empty (no archiving). Supports +// Go duration format (h, m, s) and "Nd" for days. TTL cannot exceed 6 months. +func ttlToArchiveDate(ttl string) (int64, error) { + if ttl == "" { + return 0, nil + } + var d time.Duration + if strings.HasSuffix(strings.ToLower(ttl), "d") { + numStr := strings.TrimSuffix(strings.ToLower(ttl), "d") + n, err := strconv.Atoi(numStr) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid TTL: %q", ttl). + WithRemediation("Use a duration like 2h, 1d, or 7d") + } + d = time.Duration(n) * 24 * time.Hour + } else { + var err error + d, err = time.ParseDuration(ttl) + if err != nil { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Invalid TTL: %q", ttl). + WithRemediation("Use a duration like 2h, 1d, or 7d") + } + } + if d > maxTTL { + return 0, slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("TTL cannot exceed 6 months"). + WithRemediation("Use a shorter duration (e.g., 2h, 1d, 7d)") + } + return time.Now().Add(d).Unix(), nil +} + +// slugFromsandboxName derives a domain-safe slug from org name (lowercase, alphanumeric + hyphens). +func slugFromsandboxName(name string) string { + var b []byte + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b = append(b, byte(r)) + } else if r >= 'A' && r <= 'Z' { + b = append(b, byte(r+32)) + } else if r == ' ' || r == '-' || r == '_' { + if len(b) > 0 && b[len(b)-1] != '-' { + b = append(b, '-') + } + } + } + // Trim leading/trailing hyphens + for len(b) > 0 && b[0] == '-' { + b = b[1:] + } + for len(b) > 0 && b[len(b)-1] == '-' { + b = b[:len(b)-1] + } + if len(b) == 0 { + return "sandbox" + } + return string(b) +} + +func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, result types.CreateSandboxResult) { + ctx := cmd.Context() + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "beach_with_umbrella", + Text: " Sandbox Created", + Secondary: []string{ + fmt.Sprintf("Team ID: %s", result.TeamID), + fmt.Sprintf("User ID: %s", result.UserID), + fmt.Sprintf("URL: %s", result.URL), + }, + })) +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go new file mode 100644 index 00000000..4617a519 --- /dev/null +++ b/cmd/sandbox/delete.go @@ -0,0 +1,114 @@ +// 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" + + "github.com/slackapi/slack-cli/internal/iostreams" + "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" +) + +type deleteFlags struct { + sandboxID string + force bool + yes bool + token string +} + +var deleteCmdFlags deleteFlags + +func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete [flags]", + Short: "Delete a sandbox", + Long: `Permanently delete a sandbox and all of its data`, + Example: style.ExampleCommandsf([]style.ExampleCommand{ + {Command: "sandbox delete --sandbox E0123456", Meaning: "Delete a sandbox identified by its team ID"}, + }), + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return requireSandboxExperiment(clients) + }, + RunE: func(cmd *cobra.Command, args []string) error { + return runDeleteCommand(cmd, clients) + }, + } + + cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox", "", "Sandbox team ID to delete") + cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt") + cmd.Flags().StringVar(&deleteCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + cmd.MarkFlagRequired("sandbox") + + return cmd +} + +func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { + ctx := cmd.Context() + + token, auth, err := getSandboxTokenAndAuth(ctx, clients, deleteCmdFlags.token) + if err != nil { + return err + } + + skipConfirm := deleteCmdFlags.force || deleteCmdFlags.yes + if !skipConfirm { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "warning", + Text: style.Bold(" Danger zone"), + Secondary: []string{ + fmt.Sprintf("Sandbox (%s) and all of its data will be permanently deleted", deleteCmdFlags.sandboxID), + "This cannot be undone", + }, + })) + + proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to delete the sandbox?", false) + if err != nil { + if slackerror.Is(err, slackerror.ErrProcessInterrupted) { + clients.IO.SetExitCode(iostreams.ExitCancel) + } + return err + } + if !proceed { + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "thumbs_up", + Text: "Deletion cancelled", + })) + return nil + } + } + + if err := clients.API().DeleteSandbox(ctx, token, deleteCmdFlags.sandboxID); err != nil { + return err + } + + clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ + Emoji: "white_check_mark", + Text: "Sandbox deleted", + Secondary: []string{ + "Sandbox " + deleteCmdFlags.sandboxID + " has been permanently deleted", + }, + })) + + err = printSandboxes(cmd, clients, token, auth) + if err != nil { + return err + } + + return nil +} diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go new file mode 100644 index 00000000..f50cdb49 --- /dev/null +++ b/cmd/sandbox/list.go @@ -0,0 +1,129 @@ +// 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 `")) + return nil + } + + timeFormat := "2006-01-02 15:04" + 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.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.DateCreated > 0 { + cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Created: %s", time.Unix(s.DateCreated, 0).Format(timeFormat)))) + } + if s.DateArchived > 0 { + cmd.Printf(" %s\n", style.Secondary(fmt.Sprintf("Archived: %s", time.Unix(s.DateArchived, 0).Format(timeFormat)))) + } + cmd.Println() + } + + return nil +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go new file mode 100644 index 00000000..f577273b --- /dev/null +++ b/cmd/sandbox/sandbox.go @@ -0,0 +1,58 @@ +// 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 [flags] --experiment=sandboxes", + Short: "Create and manage your sandboxes", + Long: `Create, list, or delete 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 https://api.slack.com/developer-program/sandboxes + +New to the Developer Program? Sign up at 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(NewCreateCommand(clients)) + cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewDeleteCommand(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 +} diff --git a/cmd/sandbox/token.go b/cmd/sandbox/token.go new file mode 100644 index 00000000..2cdca86d --- /dev/null +++ b/cmd/sandbox/token.go @@ -0,0 +1,107 @@ +// 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" + "strings" + + "github.com/slackapi/slack-cli/internal/shared" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" +) + +// getSandboxToken returns the token to use for sandbox API operations. +// It uses the --token flag if provided, otherwise resolves from stored credentials. +func getSandboxToken(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, error) { + token, _, err := getSandboxTokenAndAuth(ctx, clients, tokenFlag) + return token, err +} + +// getSandboxTokenAndAuth returns the token and auth used for sandbox API operations. +// When --token is provided, auth is resolved via AuthWithToken (may have limited fields). +// Otherwise auth comes from stored credentials. +func getSandboxTokenAndAuth(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, *types.SlackAuth, error) { + if tokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, tokenFlag) + if err != nil { + return "", nil, err + } + return tokenFlag, &auth, nil + } + + auth, err := resolveAuthForSandbox(ctx, clients) + if err != nil { + return "", nil, err + } + + return auth.Token, auth, nil +} + +// resolveAuthForSandbox gets the appropriate auth for sandbox operations. +// If the global --token flag is set, that is used. Otherwise, if --team is set, +// uses the auth that matches that team; else the first available auth. +func resolveAuthForSandbox(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { + // Check persistent token flag (from root) + if clients.Config.TokenFlag != "" { + auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) + if err != nil { + return nil, err + } + return &auth, nil + } + + auths, err := clients.Auth().Auths(ctx) + if err != nil { + return nil, err + } + + if len(auths) == 0 { + return nil, slackerror.New(slackerror.ErrCredentialsNotFound). + WithMessage("You must be logged in to manage sandboxes"). + WithRemediation("Run 'slack login' to authenticate, or use --token for CI/CD") + } + + // If --team flag is set, find matching auth + if clients.Config.TeamFlag != "" { + for _, auth := range auths { + if auth.TeamID == clients.Config.TeamFlag || auth.TeamDomain == clients.Config.TeamFlag { + return &auth, nil + } + } + return nil, slackerror.New(slackerror.ErrTeamNotFound). + WithMessage("No auth found for team: " + clients.Config.TeamFlag). + WithRemediation("Run 'slack auth list' to see your authorized workspaces") + } + + // Use first auth + return &auths[0], nil +} + +// parseLabels parses a comma-separated key=value string into a map +func parseLabels(labelsStr string) map[string]string { + if labelsStr == "" { + return nil + } + + labels := make(map[string]string) + for _, pair := range strings.Split(labelsStr, ",") { + kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) + if len(kv) == 2 { + labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) + } + } + return labels +} diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 898dd036..0789e1a2 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -213,6 +213,23 @@ func (m *APIMock) FunctionDistributionRemoveUsers(ctx context.Context, callbackI return args.Error(0) } +// SandboxClient + +func (m *APIMock) CreateSandbox(ctx context.Context, token string, orgName, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, error) { + args := m.Called(ctx, token, orgName, domain, password, locale, owningOrgID, templateID, eventCode, archiveDate) + return args.Get(0).(types.CreateSandboxResult), args.Error(1) +} + +func (m *APIMock) ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) { + args := m.Called(ctx, token, filter) + return args.Get(0).([]types.Sandbox), args.Error(1) +} + +func (m *APIMock) DeleteSandbox(ctx context.Context, token string, sandboxTeamID string) error { + args := m.Called(ctx, token, sandboxTeamID) + return args.Error(0) +} + // DatastoresClient func (m *APIMock) AppsDatastorePut(ctx context.Context, token string, request types.AppDatastorePut) (types.AppDatastorePutResult, error) { diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go new file mode 100644 index 00000000..0ac300c1 --- /dev/null +++ b/internal/api/sandbox.go @@ -0,0 +1,173 @@ +// 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 api + +import ( + "context" + "fmt" + "net/url" + "strconv" + + "github.com/opentracing/opentracing-go" + "github.com/slackapi/slack-cli/internal/goutils" + "github.com/slackapi/slack-cli/internal/shared/types" + "github.com/slackapi/slack-cli/internal/slackerror" +) + +const ( + sandboxCreateMethod = "enterprise.signup.createDevOrg" + sandboxListMethod = "developer.sandbox.list" + sandboxDeleteMethod = "developer.sandbox.delete" +) + +// SandboxClient is the interface for sandbox-related API calls +type SandboxClient interface { + CreateSandbox(ctx context.Context, token string, name, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, error) + ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) + DeleteSandbox(ctx context.Context, token string, sandboxTeamID string) error +} + +type createSandboxResponse struct { + extendedBaseResponse + types.CreateSandboxResult +} + +type listSandboxesResponse struct { + extendedBaseResponse + Sandboxes []types.Sandbox `json:"sandboxes"` +} + +var listSandboxFilterEnum = []string{"active", "archived"} + +// CreateSandbox provisions a new Developer Sandbox (developer org and primary user). +func (c *Client) CreateSandbox(ctx context.Context, token string, name, domain, password, locale, owningOrgID, templateID, eventCode string, archiveDate int64) (types.CreateSandboxResult, error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.CreateSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("org_name", name) + values.Add("domain", domain) + values.Add("password", password) + if locale != "" { + values.Add("locale", locale) + } + if owningOrgID != "" { + values.Add("owning_org_id", owningOrgID) + } + if templateID != "" { + values.Add("template_id", templateID) + } + if eventCode != "" { + values.Add("event_code", eventCode) + } + if archiveDate > 0 { + values.Add("archive_date", strconv.FormatInt(archiveDate, 10)) + } + + b, err := c.postForm(ctx, sandboxCreateMethod, values) + if err != nil { + return types.CreateSandboxResult{}, errHTTPRequestFailed.WithRootCause(err) + } + + resp := createSandboxResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return types.CreateSandboxResult{}, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxCreateMethod) + } + + if !resp.Ok { + return types.CreateSandboxResult{}, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxCreateMethod) + } + + return resp.CreateSandboxResult, nil +} + +// ListSandboxes returns all sandboxes owned by the Developer Account with an email address that matches the authenticated user +func (c *Client) ListSandboxes(ctx context.Context, token string, status string) ([]types.Sandbox, error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.ListSandboxes") + defer span.Finish() + + if status != "" { + valid := false + for _, v := range listSandboxFilterEnum { + if status == v { + valid = true + break + } + } + if !valid { + return nil, errInvalidArguments.WithRootCause(fmt.Errorf("allowed values for sandbox status filter: %v", listSandboxFilterEnum)) + } + } + + values := url.Values{} + values.Add("token", token) + if status != "" { + values.Add("status", status) + } + + b, err := c.postForm(ctx, sandboxListMethod, values) + if err != nil { + return nil, errHTTPRequestFailed.WithRootCause(err) + } + + resp := listSandboxesResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return nil, errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxListMethod) + } + + if !resp.Ok { + return nil, slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxListMethod) + } + + if resp.Sandboxes == nil { + return []types.Sandbox{}, nil + } + + return resp.Sandboxes, nil +} + +// DeleteSandbox permanently deletes the specified sandbox. +// Required: token, sandbox_team_id +func (c *Client) DeleteSandbox(ctx context.Context, token string, sandboxTeamID string) error { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.DeleteSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("sandbox_team_id", sandboxTeamID) + + b, err := c.postForm(ctx, sandboxDeleteMethod, values) + if err != nil { + return errHTTPRequestFailed.WithRootCause(err) + } + + resp := extendedBaseResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxDeleteMethod) + } + + if !resp.Ok { + return slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxDeleteMethod) + } + + return nil +} diff --git a/internal/api/types.go b/internal/api/types.go index e9cfe4a1..22f0431c 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -28,6 +28,7 @@ type APIInterface interface { DatastoresClient ExternalAuthClient FunctionDistributionClient + SandboxClient SessionsClient StepsClient TeamClient diff --git a/internal/experiment/experiment.go b/internal/experiment/experiment.go index c7b90149..bad7cf7d 100644 --- a/internal/experiment/experiment.go +++ b/internal/experiment/experiment.go @@ -41,6 +41,9 @@ const ( // Charm experiment enables beautiful prompts. Charm Experiment = "charm" + // Sandboxes experiment lets users who have joined the Slack Developer Program use the CLI to manage their sandboxes. + Sandboxes Experiment = "sandboxes" + // Placeholder experiment is a placeholder for testing and does nothing... or does it? Placeholder Experiment = "placeholder" ) @@ -52,6 +55,7 @@ var AllExperiments = []Experiment{ BoltInstall, Charm, Placeholder, + Sandboxes, } // EnabledExperiments is a list of experiments that are permanently enabled diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go new file mode 100644 index 00000000..88fa0161 --- /dev/null +++ b/internal/shared/types/sandbox.go @@ -0,0 +1,47 @@ +// 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 types + +// Sandbox represents a Slack Developer Sandbox from the developer.sandbox.list API. +type Sandbox struct { + SandboxTeamID string `json:"sandbox_team_id"` // Encoded team ID of the developer sandbox + SandboxName string `json:"sandbox_name"` // Name of the developer sandbox + SandboxDomain string `json:"sandbox_domain"` // Domain of the developer sandbox + DateCreated int64 `json:"date_created"` // When the developer sandbox was created, as epoch seconds + DateArchived int64 `json:"date_archived"` // When the developer sandbox is or will be archived, as epoch seconds + Status string `json:"status"` // Status of the developer sandbox: Active or Archived +} + +// CreateSandboxRequest is the request payload for creating a sandbox. +// Matches enterprise.signup.createDevOrg API contract. +type CreateSandboxRequest struct { + Token string `json:"token"` + OrgName string `json:"org_name"` + Domain string `json:"domain"` + Password string `json:"password,omitempty"` + Locale string `json:"locale,omitempty"` + OwningOrgID string `json:"owning_org_id,omitempty"` + TemplateID string `json:"template_id,omitempty"` + EventCode string `json:"event_code,omitempty"` + ArchiveDate int64 `json:"archive_date,omitempty"` // When the sandbox will be archived, as epoch seconds +} + +// CreateSandboxResult is the response from creating a sandbox. +// Matches enterprise.signup.createDevOrg API output. +type CreateSandboxResult struct { + UserID string `json:"user_id"` + TeamID string `json:"team_id"` + URL string `json:"url"` +} diff --git a/internal/shared/types/user.go b/internal/shared/types/user.go index 10738da5..4f56b73b 100644 --- a/internal/shared/types/user.go +++ b/internal/shared/types/user.go @@ -72,4 +72,5 @@ type UserInfo struct { type UserProfile struct { DisplayName string `json:"display_name"` + Email string `json:"email,omitempty"` } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index a9d89ea3..e6d86224 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -268,6 +268,7 @@ const ( ErrUserRemovedFromTeam = "user_removed_from_team" ErrWorkflowNotFound = "workflow_not_found" ErrYaml = "yaml_error" + ErrSandboxDomainTaken = "domain_taken" ) var ErrorCodeMap = map[string]Error{ @@ -1120,7 +1121,7 @@ Otherwise start your app for local development with: %s`, ErrMissingExperiment: { Code: ErrMissingExperiment, - Message: "The feature is behind an experiment not toggled on", + Message: "The feature is behind an experiment flag", }, ErrMissingFunctionIdentifier: { @@ -1614,4 +1615,9 @@ Otherwise start your app for local development with: %s`, Code: ErrYaml, Message: "An error occurred while parsing the app manifest YAML file", }, + + ErrSandboxDomainTaken: { + Code: ErrSandboxDomainTaken, + Message: "This domain has been claimed by another sandbox", + }, } From cea2bcd20b70040399674f0cce7c0736c019cdbe Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Wed, 11 Mar 2026 13:28:50 -0400 Subject: [PATCH 2/3] updates --- cmd/app/delete.go | 2 +- cmd/sandbox/create.go | 122 ++++++------ cmd/sandbox/create_test.go | 317 +++++++++++++++++++++++++++++++ cmd/sandbox/delete.go | 19 +- cmd/sandbox/delete_test.go | 183 ++++++++++++++++++ cmd/sandbox/sandbox.go | 3 + cmd/sandbox/token.go | 107 ----------- internal/api/api_mock.go | 10 + internal/api/sandbox.go | 89 ++++++++- internal/shared/types/sandbox.go | 14 +- internal/slackerror/errors.go | 2 +- 11 files changed, 682 insertions(+), 186 deletions(-) create mode 100644 cmd/sandbox/create_test.go create mode 100644 cmd/sandbox/delete_test.go delete mode 100644 cmd/sandbox/token.go diff --git a/cmd/app/delete.go b/cmd/app/delete.go index 4375627a..30d3264b 100644 --- a/cmd/app/delete.go +++ b/cmd/app/delete.go @@ -111,7 +111,7 @@ func RunDeleteCommand(ctx context.Context, clients *shared.ClientFactory, cmd *c func confirmDeletion(ctx context.Context, IO iostreams.IOStreamer, app prompts.SelectedApp) (bool, error) { IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", - Text: style.Bold(" Danger zone"), + Text: style.Bold("Danger zone"), Secondary: []string{ fmt.Sprintf("App (%s) will be permanently deleted", app.App.AppID), "All triggers, workflows, and functions will be deleted", diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 7aea6286..366484dd 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -15,14 +15,12 @@ package sandbox import ( - "encoding/json" "fmt" "strconv" "strings" "time" "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" "github.com/slackapi/slack-cli/internal/slackerror" "github.com/slackapi/slack-cli/internal/style" "github.com/spf13/cobra" @@ -36,10 +34,8 @@ type createFlags struct { owningOrgID string template string eventCode string - ttl string - autoLogin bool - output string - token string + archiveTTL string // TTL duration, e.g. 1d, 2h + archiveDate string // explicit date yyyy-mm-dd } var createCmdFlags createFlags @@ -47,14 +43,12 @@ var createCmdFlags createFlags func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "create [flags]", - Short: "Create a new sandbox", - Long: `Create a new Slack developer sandbox. - -Provisions a new sandbox. Domain is derived from org name if --domain is not provided.`, + Short: "Create a developer sandbox", + Long: `Create a new Slack developer sandbox`, Example: style.ExampleCommandsf([]style.ExampleCommand{ - {Command: "sandbox create --name test-box", Meaning: "Create a sandbox named test-box"}, - {Command: "sandbox create --name test-box --password mypass --owning-org-id E12345", Meaning: "Create a sandbox with login password and owning org"}, - {Command: "sandbox create --name test-box --domain test-box --ttl 24h --output json", Meaning: "Create an ephemeral sandbox for CI/CD with JSON output"}, + {Command: "sandbox create --name test-box --password mypass", Meaning: "Create a sandbox named test-box"}, + {Command: "sandbox create --name test-box --password mypass --domain test-box --archive-ttl 1d", Meaning: "Create a temporary sandbox that will be archived in 1 day"}, + {Command: "sandbox create --name test-box --password mypass --domain test-box --archive-date 2025-12-31", Meaning: "Create a sandbox that will be archived on a specific date"}, }), Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -68,16 +62,21 @@ Provisions a new sandbox. Domain is derived from org name if --domain is not pro cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox") cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain (e.g., pizzaknifefight). If not provided, derived from org name") cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox") - cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (eg. en-us, languageCode-countryCode)") cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload") cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox") - cmd.Flags().StringVar(&createCmdFlags.ttl, "ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)") - cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text") - cmd.Flags().StringVar(&createCmdFlags.token, "token", "", "Service account token for CI/CD authentication") + cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration; sandbox will be archived at end of day after this period (e.g., 2h, 1d, 7d)") + cmd.Flags().StringVar(&createCmdFlags.archiveDate, "archive-date", "", "Explicit archive date in yyyy-mm-dd format. Cannot be used with --archive") - cmd.MarkFlagRequired("name") - cmd.MarkFlagRequired("domain") - cmd.MarkFlagRequired("password") + // If one's developer account is managed by multiple Production Slack teams, one of those team IDs must be provided in the command + cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable") + + if err := cmd.MarkFlagRequired("name"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("password"); err != nil { + panic(err) + } return cmd } @@ -85,22 +84,36 @@ Provisions a new sandbox. Domain is derived from org name if --domain is not pro func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { ctx := cmd.Context() - token, err := getSandboxToken(ctx, clients, createCmdFlags.token) + auth, err := getSandboxAuth(ctx, clients) if err != nil { return err } domain := createCmdFlags.domain if domain == "" { - domain = slugFromsandboxName(createCmdFlags.name) + domain = domainFromName(createCmdFlags.name) } - archiveDate, err := ttlToArchiveDate(createCmdFlags.ttl) - if err != nil { - return err + if createCmdFlags.archiveTTL != "" && createCmdFlags.archiveDate != "" { + return slackerror.New(slackerror.ErrInvalidArguments). + WithMessage("Cannot use both --archive-ttl and --archive-date"). + WithRemediation("Use only one: --archive-ttl for TTL (e.g., 3d) or --archive-date for a specific date (yyyy-mm-dd)") + } + + archiveEpochDatetime := int64(0) + if createCmdFlags.archiveTTL != "" { + archiveEpochDatetime, err = getEpochFromTTL(createCmdFlags.archiveTTL) + if err != nil { + return err + } + } else if createCmdFlags.archiveDate != "" { + archiveEpochDatetime, err = getEpochFromDate(createCmdFlags.archiveDate) + if err != nil { + return err + } } - result, err := clients.API().CreateSandbox(ctx, token, + teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token, createCmdFlags.name, domain, createCmdFlags.password, @@ -108,39 +121,21 @@ func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { createCmdFlags.owningOrgID, createCmdFlags.template, createCmdFlags.eventCode, - archiveDate, + archiveEpochDatetime, ) if err != nil { return err } - switch createCmdFlags.output { - case "json": - encoder := json.NewEncoder(clients.IO.WriteOut()) - encoder.SetIndent("", " ") - if err := encoder.Encode(result); err != nil { - return err - } - default: - printCreateSuccess(cmd, clients, result) - } - - if createCmdFlags.autoLogin && result.URL != "" { - clients.Browser().OpenURL(result.URL) - } + printCreateSuccess(cmd, clients, teamID, sandboxURL) return nil } -const maxTTL = 180 * 24 * time.Hour // 6 months - -// ttlToArchiveDate parses a TTL string (e.g., "24h", "1d", "7d") and returns the Unix epoch -// when the sandbox will be archived. Returns 0 if ttl is empty (no archiving). Supports -// Go duration format (h, m, s) and "Nd" for days. TTL cannot exceed 6 months. -func ttlToArchiveDate(ttl string) (int64, error) { - if ttl == "" { - return 0, nil - } +// getEpochFromTTL parses a time-to-live string (e.g., "24h", "1d", "7d") and returns the Unix epoch +// when the sandbox will be archived. Supports Go duration format (h, m, s) and "Nd" for days. +// The value cannot exceed 6 months. +func getEpochFromTTL(ttl string) (int64, error) { var d time.Duration if strings.HasSuffix(strings.ToLower(ttl), "d") { numStr := strings.TrimSuffix(strings.ToLower(ttl), "d") @@ -160,16 +155,23 @@ func ttlToArchiveDate(ttl string) (int64, error) { WithRemediation("Use a duration like 2h, 1d, or 7d") } } - if d > maxTTL { + return time.Now().Add(d).Unix(), nil +} + +// getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at start of that day (UTC). +func getEpochFromDate(dateStr string) (int64, error) { + dateFormat := "2006-01-02" + t, err := time.ParseInLocation(dateFormat, dateStr, time.UTC) + if err != nil { return 0, slackerror.New(slackerror.ErrInvalidArguments). - WithMessage("TTL cannot exceed 6 months"). - WithRemediation("Use a shorter duration (e.g., 2h, 1d, 7d)") + WithMessage("Invalid archive date: %q", dateStr). + WithRemediation("Use yyyy-mm-dd format (e.g., 2025-12-31)") } - return time.Now().Add(d).Unix(), nil + return t.Unix(), nil } -// slugFromsandboxName derives a domain-safe slug from org name (lowercase, alphanumeric + hyphens). -func slugFromsandboxName(name string) string { +// domainFromName derives domain-safe text from the name of the sandbox (lowercase, alphanumeric + hyphens). +func domainFromName(name string) string { var b []byte for _, r := range name { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { @@ -195,15 +197,15 @@ func slugFromsandboxName(name string) string { return string(b) } -func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, result types.CreateSandboxResult) { +func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, teamID, url string) { ctx := cmd.Context() clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "beach_with_umbrella", Text: " Sandbox Created", Secondary: []string{ - fmt.Sprintf("Team ID: %s", result.TeamID), - fmt.Sprintf("User ID: %s", result.UserID), - fmt.Sprintf("URL: %s", result.URL), + fmt.Sprintf("Team ID: %s", teamID), + fmt.Sprintf("URL: %s", url), }, })) + clients.IO.PrintInfo(ctx, false, "Manage this sandbox from the CLI or visit\n%s", style.Secondary("https://api.slack.com/developer-program/sandboxes")) } diff --git a/cmd/sandbox/create_test.go b/cmd/sandbox/create_test.go new file mode 100644 index 00000000..a3a17565 --- /dev/null +++ b/cmd/sandbox/create_test.go @@ -0,0 +1,317 @@ +// 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/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestCreateCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "create success": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "test-box", + "--domain", "test-box", + "--password", "mypass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "test-box", "test-box", "mypass", "", "", "", "", int64(0)). + Return("T123", "https://test-box.slack.com", nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T123", "https://test-box.slack.com", "Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, "xoxb-test-token") + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "test-box", "test-box", "mypass", "", "", "", "", int64(0)) + }, + }, + "create with json-box": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "json-box", + "--domain", "json-box", + "--password", "secret", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "json-box", "json-box", "secret", "", "", "", "", int64(0)). + Return("T456", "https://json-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"T456", "https://json-box.slack.com", "Sandbox Created"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "json-box", "json-box", "secret", "", "", "", "", int64(0)) + }, + }, + "create with derived domain": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "My Test Box", + "--domain", "my-test-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "My Test Box", "my-test-box", "pass", "", "", "", "", int64(0)). + Return("T789", "https://my-test-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "My Test Box", "my-test-box", "pass", "", "", "", "", int64(0)) + }, + }, + "create with a relative time-to-live value": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "24h", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "tmp-box", "tmp-box", "pass", "", "", "", "", mock.MatchedBy(func(v int64) bool { return v > 0 })). + Return("T111", "https://tmp-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "tmp-box", "tmp-box", "pass", "", "", "", "", mock.MatchedBy(func(v int64) bool { return v > 0 })) + }, + }, + "create API error": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "err-box", + "--domain", "err-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "err-box", "err-box", "pass", "", "", "", "", int64(0)). + Return("", "", errors.New("api_error")) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"api_error"}, + }, + "create with archive-date": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "date-box", + "--domain", "date-box", + "--password", "pass", + "--archive-date", "2025-12-31", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("CreateSandbox", mock.Anything, testToken, "date-box", "date-box", "pass", "", "", "", "", int64(1767139200)). + Return("T222", "https://date-box.slack.com", nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "CreateSandbox", mock.Anything, "xoxb-test-token", "date-box", "date-box", "pass", "", "", "", "", int64(1767139200)) + }, + }, + "create with both archive and archive-date fails": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "1d", + "--archive-date", "2025-12-31", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"Cannot use both --archive-ttl and --archive-date"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "invalid archive value": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--name", "tmp-box", + "--domain", "tmp-box", + "--password", "pass", + "--archive-ttl", "invalid", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"Invalid TTL"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + "experiment required": { + CmdArgs: []string{ + "--name", "test-box", + "--domain", "test-box", + "--password", "pass", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + }, + ExpectedErrorStrings: []string{"sandbox"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "CreateSandbox", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewCreateCommand(cf) + }) +} + +func Test_getEpochFromTTL(t *testing.T) { + tests := []struct { + name string + ttl string + wantErr bool + }{ + {"24h", "24h", false}, + {"1d", "1d", false}, + {"7d", "7d", false}, + {"invalid", "invalid", true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEpochFromTTL(tt.ttl) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Greater(t, got, int64(0), "archive date should be in the future") + }) + } +} + +func Test_getEpochFromDate(t *testing.T) { + tests := []struct { + name string + dateStr string + want int64 + wantErr bool + }{ + {"valid", "2025-12-31", 1767139200, false}, // 2025-12-31 00:00:00 UTC + {"invalid format", "12-31-2025", 0, true}, + {"invalid date", "not-a-date", 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := getEpochFromDate(tt.dateStr) + if tt.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func Test_domainFromName(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"simple", "test-box", "test-box"}, + {"spaces", "My Test Box", "my-test-box"}, + {"uppercase", "MyBox", "mybox"}, + {"mixed", "Hello_World 123", "hello-world-123"}, + {"hyphens", "a--b", "a-b"}, + {"leading trailing", "-test-", "test"}, + {"empty", "", "sandbox"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := domainFromName(tt.in) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 4617a519..62976218 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -28,7 +28,6 @@ type deleteFlags struct { sandboxID string force bool yes bool - token string } var deleteCmdFlags deleteFlags @@ -36,10 +35,10 @@ var deleteCmdFlags deleteFlags func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { cmd := &cobra.Command{ Use: "delete [flags]", - Short: "Delete a sandbox", + Short: "Delete a developer sandbox", Long: `Permanently delete a sandbox and all of its data`, Example: style.ExampleCommandsf([]style.ExampleCommand{ - {Command: "sandbox delete --sandbox E0123456", Meaning: "Delete a sandbox identified by its team ID"}, + {Command: "sandbox delete --sandbox-id E0123456", Meaning: "Delete a sandbox identified by its team ID"}, }), Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { @@ -50,10 +49,12 @@ func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { }, } - cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox", "", "Sandbox team ID to delete") + cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox-id", "", "Sandbox team ID to delete") cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt") - cmd.Flags().StringVar(&deleteCmdFlags.token, "token", "", "Service account token for CI/CD authentication") - cmd.MarkFlagRequired("sandbox") + + if err := cmd.MarkFlagRequired("sandbox-id"); err != nil { + panic(err) + } return cmd } @@ -61,7 +62,7 @@ func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command { func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { ctx := cmd.Context() - token, auth, err := getSandboxTokenAndAuth(ctx, clients, deleteCmdFlags.token) + auth, err := getSandboxAuth(ctx, clients) if err != nil { return err } @@ -93,7 +94,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { } } - if err := clients.API().DeleteSandbox(ctx, token, deleteCmdFlags.sandboxID); err != nil { + if err := clients.API().DeleteSandbox(ctx, auth.Token, deleteCmdFlags.sandboxID); err != nil { return err } @@ -105,7 +106,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { }, })) - err = printSandboxes(cmd, clients, token, auth) + err = printSandboxes(cmd, clients, auth.Token, auth) if err != nil { return err } diff --git a/cmd/sandbox/delete_test.go b/cmd/sandbox/delete_test.go new file mode 100644 index 00000000..975051e1 --- /dev/null +++ b/cmd/sandbox/delete_test.go @@ -0,0 +1,183 @@ +// 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/test/testutil" + "github.com/spf13/cobra" + "github.com/stretchr/testify/mock" +) + +func TestDeleteCommand(t *testing.T) { + testutil.TableTestCommand(t, testutil.CommandTests{ + "delete success": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(nil) + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox deleted", "T123", "No sandboxes found"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.Auth.AssertCalled(t, "AuthWithToken", mock.Anything, "xoxb-test-token") + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "") + }, + }, + "delete with remaining sandboxes": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(nil) + sandboxes := []types.Sandbox{ + { + SandboxTeamID: "T456", + SandboxName: "other-sandbox", + SandboxDomain: "other-sandbox", + Status: "active", + DateCreated: 1700000000, + DateArchived: 0, + }, + } + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return(sandboxes, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox deleted", "T123", "other-sandbox", "T456"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + cm.API.AssertCalled(t, "ListSandboxes", mock.Anything, "xoxb-test-token", "") + }, + }, + "deletion cancelled": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false).Return(false, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Deletion cancelled"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false) + cm.API.AssertNotCalled(t, "DeleteSandbox", mock.Anything, mock.Anything, mock.Anything) + }, + }, + "delete confirmation proceeds": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "E0123456", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken, UserID: "U123"}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.IO.On("ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false).Return(true, nil) + cm.API.On("DeleteSandbox", mock.Anything, testToken, "E0123456").Return(nil) + cm.API.On("ListSandboxes", mock.Anything, testToken, "").Return([]types.Sandbox{}, nil) + cm.API.On("UsersInfo", mock.Anything, mock.Anything, mock.Anything).Return(&types.UserInfo{Profile: types.UserProfile{}}, nil) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedStdoutOutputs: []string{"Sandbox deleted", "E0123456"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.IO.AssertCalled(t, "ConfirmPrompt", mock.Anything, "Are you sure you want to delete the sandbox?", false) + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "E0123456") + }, + }, + "delete API error": { + CmdArgs: []string{ + "--experiment=sandboxes", + "--token", "xoxb-test-token", + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + testToken := "xoxb-test-token" + cm.Auth.On("AuthWithToken", mock.Anything, testToken).Return(types.SlackAuth{Token: testToken}, nil) + cm.Auth.On("ResolveAPIHost", mock.Anything, mock.Anything, mock.Anything).Return("https://api.slack.com") + cm.Auth.On("ResolveLogstashHost", mock.Anything, mock.Anything, mock.Anything).Return("https://slackb.com/events/cli") + cm.API.On("DeleteSandbox", mock.Anything, testToken, "T123").Return(errors.New("api_error")) + + cm.AddDefaultMocks() + cm.Config.ExperimentsFlag = []string{string(experiment.Sandboxes)} + cm.Config.LoadExperiments(ctx, cm.IO.PrintDebug) + }, + ExpectedErrorStrings: []string{"api_error"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertCalled(t, "DeleteSandbox", mock.Anything, "xoxb-test-token", "T123") + }, + }, + "experiment required": { + CmdArgs: []string{ + "--sandbox-id", "T123", + "--force", + }, + Setup: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock, cf *shared.ClientFactory) { + cm.AddDefaultMocks() + }, + ExpectedErrorStrings: []string{"sandbox"}, + ExpectedAsserts: func(t *testing.T, ctx context.Context, cm *shared.ClientsMock) { + cm.API.AssertNotCalled(t, "DeleteSandbox", mock.Anything, mock.Anything, mock.Anything) + }, + }, + }, func(cf *shared.ClientFactory) *cobra.Command { + return NewDeleteCommand(cf) + }) +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index a149c3f5..7a50e6a4 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -49,6 +49,8 @@ New to the Developer Program? Sign up at } cmd.AddCommand(NewListCommand(clients)) + cmd.AddCommand(NewCreateCommand(clients)) + cmd.AddCommand(NewDeleteCommand(clients)) return cmd } @@ -75,6 +77,7 @@ func getSandboxAuth(ctx context.Context, clients *shared.ClientFactory) (*types. } // Prompt the user to select a team to use for authentication + clients.IO.PrintInfo(ctx, false, style.Secondary("Choose a Slack team where your email address matches your Slack developer account")) auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a team for authentication") if err != nil { return nil, err diff --git a/cmd/sandbox/token.go b/cmd/sandbox/token.go deleted file mode 100644 index 2cdca86d..00000000 --- a/cmd/sandbox/token.go +++ /dev/null @@ -1,107 +0,0 @@ -// 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" - "strings" - - "github.com/slackapi/slack-cli/internal/shared" - "github.com/slackapi/slack-cli/internal/shared/types" - "github.com/slackapi/slack-cli/internal/slackerror" -) - -// getSandboxToken returns the token to use for sandbox API operations. -// It uses the --token flag if provided, otherwise resolves from stored credentials. -func getSandboxToken(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, error) { - token, _, err := getSandboxTokenAndAuth(ctx, clients, tokenFlag) - return token, err -} - -// getSandboxTokenAndAuth returns the token and auth used for sandbox API operations. -// When --token is provided, auth is resolved via AuthWithToken (may have limited fields). -// Otherwise auth comes from stored credentials. -func getSandboxTokenAndAuth(ctx context.Context, clients *shared.ClientFactory, tokenFlag string) (string, *types.SlackAuth, error) { - if tokenFlag != "" { - auth, err := clients.Auth().AuthWithToken(ctx, tokenFlag) - if err != nil { - return "", nil, err - } - return tokenFlag, &auth, nil - } - - auth, err := resolveAuthForSandbox(ctx, clients) - if err != nil { - return "", nil, err - } - - return auth.Token, auth, nil -} - -// resolveAuthForSandbox gets the appropriate auth for sandbox operations. -// If the global --token flag is set, that is used. Otherwise, if --team is set, -// uses the auth that matches that team; else the first available auth. -func resolveAuthForSandbox(ctx context.Context, clients *shared.ClientFactory) (*types.SlackAuth, error) { - // Check persistent token flag (from root) - if clients.Config.TokenFlag != "" { - auth, err := clients.Auth().AuthWithToken(ctx, clients.Config.TokenFlag) - if err != nil { - return nil, err - } - return &auth, nil - } - - auths, err := clients.Auth().Auths(ctx) - if err != nil { - return nil, err - } - - if len(auths) == 0 { - return nil, slackerror.New(slackerror.ErrCredentialsNotFound). - WithMessage("You must be logged in to manage sandboxes"). - WithRemediation("Run 'slack login' to authenticate, or use --token for CI/CD") - } - - // If --team flag is set, find matching auth - if clients.Config.TeamFlag != "" { - for _, auth := range auths { - if auth.TeamID == clients.Config.TeamFlag || auth.TeamDomain == clients.Config.TeamFlag { - return &auth, nil - } - } - return nil, slackerror.New(slackerror.ErrTeamNotFound). - WithMessage("No auth found for team: " + clients.Config.TeamFlag). - WithRemediation("Run 'slack auth list' to see your authorized workspaces") - } - - // Use first auth - return &auths[0], nil -} - -// parseLabels parses a comma-separated key=value string into a map -func parseLabels(labelsStr string) map[string]string { - if labelsStr == "" { - return nil - } - - labels := make(map[string]string) - for _, pair := range strings.Split(labelsStr, ",") { - kv := strings.SplitN(strings.TrimSpace(pair), "=", 2) - if len(kv) == 2 { - labels[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1]) - } - } - return labels -} diff --git a/internal/api/api_mock.go b/internal/api/api_mock.go index 76d784ae..171753c1 100644 --- a/internal/api/api_mock.go +++ b/internal/api/api_mock.go @@ -220,6 +220,16 @@ func (m *APIMock) ListSandboxes(ctx context.Context, token string, filter string return args.Get(0).([]types.Sandbox), args.Error(1) } +func (m *APIMock) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (string, string, error) { + args := m.Called(ctx, token, name, domain, password, locale, owningOrgID, template, eventCode, archiveDate) + return args.String(0), args.String(1), args.Error(2) +} + +func (m *APIMock) DeleteSandbox(ctx context.Context, token, sandboxID string) error { + args := m.Called(ctx, token, sandboxID) + return args.Error(0) +} + // DatastoresClient func (m *APIMock) AppsDatastorePut(ctx context.Context, token string, request types.AppDatastorePut) (types.AppDatastorePutResult, error) { diff --git a/internal/api/sandbox.go b/internal/api/sandbox.go index d6eed6b7..9396ce7d 100644 --- a/internal/api/sandbox.go +++ b/internal/api/sandbox.go @@ -17,6 +17,7 @@ package api import ( "context" "net/url" + "strconv" "github.com/opentracing/opentracing-go" "github.com/slackapi/slack-cli/internal/goutils" @@ -24,11 +25,17 @@ import ( "github.com/slackapi/slack-cli/internal/slackerror" ) -const sandboxListMethod = "developer.sandbox.list" +const ( + sandboxListMethod = "developer.sandbox.list" + sandboxCreateMethod = "enterprise.signup.createDevOrg" + sandboxDeleteMethod = "developer.sandbox.delete" +) // SandboxClient is the interface for sandbox-related API calls type SandboxClient interface { ListSandboxes(ctx context.Context, token string, filter string) ([]types.Sandbox, error) + CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) + DeleteSandbox(ctx context.Context, token, sandboxID string) error } type listSandboxesResponse struct { @@ -69,3 +76,83 @@ func (c *Client) ListSandboxes(ctx context.Context, token string, status string) return resp.Sandboxes, nil } + +type createSandboxResponse struct { + extendedBaseResponse + TeamID string `json:"team_id"` + UserID string `json:"user_id"` + URL string `json:"url"` +} + +// CreateSandbox creates a new developer sandbox +func (c *Client) CreateSandbox(ctx context.Context, token, name, domain, password, locale, owningOrgID, template, eventCode string, archiveDate int64) (teamID, sandboxURL string, err error) { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.CreateSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("org_name", name) + values.Add("domain", domain) + values.Add("password", password) + if locale != "" { + values.Add("locale", locale) + } + if owningOrgID != "" { + values.Add("owning_org_id", owningOrgID) + } + if template != "" { + values.Add("template", template) + } + if eventCode != "" { + values.Add("event_code", eventCode) + } + if archiveDate > 0 { + values.Add("archive_date", strconv.FormatInt(archiveDate, 10)) + } + + b, err := c.postForm(ctx, sandboxCreateMethod, values) + if err != nil { + return "", "", errHTTPRequestFailed.WithRootCause(err) + } + + resp := createSandboxResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return "", "", errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxCreateMethod) + } + + if !resp.Ok { + return "", "", slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxCreateMethod) + } + + return resp.TeamID, resp.URL, nil +} + +// DeleteSandbox permanently deletes a developer sandbox +func (c *Client) DeleteSandbox(ctx context.Context, token, sandboxID string) error { + var span opentracing.Span + span, ctx = opentracing.StartSpanFromContext(ctx, "apiclient.DeleteSandbox") + defer span.Finish() + + values := url.Values{} + values.Add("token", token) + values.Add("sandbox_team_id", sandboxID) + + b, err := c.postForm(ctx, sandboxDeleteMethod, values) + if err != nil { + return errHTTPRequestFailed.WithRootCause(err) + } + + resp := extendedBaseResponse{} + err = goutils.JSONUnmarshal(b, &resp) + if err != nil { + return errHTTPResponseInvalid.WithRootCause(err).AddAPIMethod(sandboxDeleteMethod) + } + + if !resp.Ok { + return slackerror.NewAPIError(resp.Error, resp.Description, resp.Errors, sandboxDeleteMethod) + } + + return nil +} diff --git a/internal/shared/types/sandbox.go b/internal/shared/types/sandbox.go index 750093f4..d214e1f4 100644 --- a/internal/shared/types/sandbox.go +++ b/internal/shared/types/sandbox.go @@ -14,12 +14,12 @@ package types -// Sandbox represents a Slack Developer Sandbox from the developer.sandbox.list API. +// Sandbox represents a Slack Developer Sandbox type Sandbox struct { - DateArchived int64 `json:"date_archived"` // When the developer sandbox is or will be archived, as epoch seconds - DateCreated int64 `json:"date_created"` // When the developer sandbox was created, as epoch seconds - SandboxDomain string `json:"sandbox_domain"` // Domain of the developer sandbox - SandboxName string `json:"sandbox_name"` // Name of the developer sandbox - SandboxTeamID string `json:"sandbox_team_id"` // Encoded team ID of the developer sandbox - Status string `json:"status"` // Status of the developer sandbox: Active or Archived + DateArchived int64 `json:"date_archived"` + DateCreated int64 `json:"date_created"` + SandboxDomain string `json:"sandbox_domain"` + SandboxName string `json:"sandbox_name"` + SandboxTeamID string `json:"sandbox_team_id"` + Status string `json:"status"` } diff --git a/internal/slackerror/errors.go b/internal/slackerror/errors.go index e6d86224..360572bd 100644 --- a/internal/slackerror/errors.go +++ b/internal/slackerror/errors.go @@ -1121,7 +1121,7 @@ Otherwise start your app for local development with: %s`, ErrMissingExperiment: { Code: ErrMissingExperiment, - Message: "The feature is behind an experiment flag", + Message: "The feature is behind an experiment not toggled on", }, ErrMissingFunctionIdentifier: { From a35fbc3e6b3aacdec35d1f0a9df689505c40db2a Mon Sep 17 00:00:00 2001 From: Elaine Vegeris Date: Thu, 12 Mar 2026 15:04:03 -0400 Subject: [PATCH 3/3] update --- cmd/sandbox/delete.go | 3 +-- cmd/sandbox/sandbox.go | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/cmd/sandbox/delete.go b/cmd/sandbox/delete.go index 62976218..24135dc3 100644 --- a/cmd/sandbox/delete.go +++ b/cmd/sandbox/delete.go @@ -27,7 +27,6 @@ import ( type deleteFlags struct { sandboxID string force bool - yes bool } var deleteCmdFlags deleteFlags @@ -67,7 +66,7 @@ func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error { return err } - skipConfirm := deleteCmdFlags.force || deleteCmdFlags.yes + skipConfirm := deleteCmdFlags.force if !skipConfirm { clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{ Emoji: "warning", diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 7a50e6a4..f34d3484 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -77,7 +77,7 @@ func getSandboxAuth(ctx context.Context, clients *shared.ClientFactory) (*types. } // Prompt the user to select a team to use for authentication - clients.IO.PrintInfo(ctx, false, style.Secondary("Choose a Slack team where your email address matches your Slack developer account")) + clients.IO.PrintInfo(ctx, false, "%s", style.Secondary("Choose a Slack team where your email address matches your Slack developer account")) auth, err := prompts.PromptTeamSlackAuth(ctx, clients, "Select a team for authentication") if err != nil { return nil, err