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: 1 addition & 1 deletion cmd/app/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
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
209 changes: 209 additions & 0 deletions cmd/sandbox/create.go
Original file line number Diff line number Diff line change
@@ -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")

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

View workflow job for this annotation

GitHub Actions / Lints and Tests

Error return value of `cmd.MarkFlagRequired` is not checked (errcheck)
cmd.MarkFlagRequired("domain")

Check failure on line 79 in cmd/sandbox/create.go

View workflow job for this annotation

GitHub Actions / Lints and Tests

Error return value of `cmd.MarkFlagRequired` is not checked (errcheck)
cmd.MarkFlagRequired("password")

Check failure on line 80 in cmd/sandbox/create.go

View workflow job for this annotation

GitHub Actions / Lints and Tests

Error return value of `cmd.MarkFlagRequired` is not checked (errcheck)

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),
},
}))
}
114 changes: 114 additions & 0 deletions cmd/sandbox/delete.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading