Skip to content

Commit c0b11c5

Browse files
committed
Sandbox management commands
1 parent c449532 commit c0b11c5

File tree

14 files changed

+870
-2
lines changed

14 files changed

+870
-2
lines changed

cmd/app/delete.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ func newDeleteLogger(clients *shared.ClientFactory, cmd *cobra.Command, envName
128128
func confirmDeletion(ctx context.Context, IO iostreams.IOStreamer, app prompts.SelectedApp) (bool, error) {
129129
IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
130130
Emoji: "warning",
131-
Text: style.Bold("Danger zone"),
131+
Text: style.Bold(" Danger zone"),
132132
Secondary: []string{
133133
fmt.Sprintf("App (%s) will be permanently deleted", app.App.AppID),
134134
"All triggers, workflows, and functions will be deleted",

cmd/root.go

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

cmd/sandbox/create.go

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package sandbox
16+
17+
import (
18+
"encoding/json"
19+
"fmt"
20+
"strconv"
21+
"strings"
22+
"time"
23+
24+
"github.com/slackapi/slack-cli/internal/shared"
25+
"github.com/slackapi/slack-cli/internal/shared/types"
26+
"github.com/slackapi/slack-cli/internal/slackerror"
27+
"github.com/slackapi/slack-cli/internal/style"
28+
"github.com/spf13/cobra"
29+
)
30+
31+
type createFlags struct {
32+
name string
33+
domain string
34+
password string
35+
locale string
36+
owningOrgID string
37+
template string
38+
eventCode string
39+
ttl string
40+
autoLogin bool
41+
output string
42+
token string
43+
}
44+
45+
var createCmdFlags createFlags
46+
47+
func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command {
48+
cmd := &cobra.Command{
49+
Use: "create [flags]",
50+
Short: "Create a new sandbox",
51+
Long: `Create a new Slack developer sandbox.
52+
53+
Provisions a new sandbox. Domain is derived from org name if --domain is not provided.`,
54+
Example: style.ExampleCommandsf([]style.ExampleCommand{
55+
{Command: "sandbox create --name test-box", Meaning: "Create a sandbox named test-box"},
56+
{Command: "sandbox create --name test-box --password mypass --owning-org-id E12345", Meaning: "Create a sandbox with login password and owning org"},
57+
{Command: "sandbox create --name test-box --domain test-box --ttl 24h --output json", Meaning: "Create an ephemeral sandbox for CI/CD with JSON output"},
58+
}),
59+
Args: cobra.NoArgs,
60+
PreRunE: func(cmd *cobra.Command, args []string) error {
61+
return requireSandboxExperiment(clients)
62+
},
63+
RunE: func(cmd *cobra.Command, args []string) error {
64+
return runCreateCommand(cmd, clients)
65+
},
66+
}
67+
68+
cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox")
69+
cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain (e.g., pizzaknifefight). If not provided, derived from org name")
70+
cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox")
71+
cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable")
72+
cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template ID for pre-defined data to preload")
73+
cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox")
74+
cmd.Flags().StringVar(&createCmdFlags.ttl, "ttl", "", "Time-to-live duration; sandbox will be archived after this period (e.g., 2h, 1d, 7d)")
75+
cmd.Flags().StringVar(&createCmdFlags.output, "output", "text", "Output format: json, text")
76+
cmd.Flags().StringVar(&createCmdFlags.token, "token", "", "Service account token for CI/CD authentication")
77+
78+
cmd.MarkFlagRequired("name")
79+
cmd.MarkFlagRequired("domain")
80+
cmd.MarkFlagRequired("password")
81+
82+
return cmd
83+
}
84+
85+
func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
86+
ctx := cmd.Context()
87+
88+
token, err := getSandboxToken(ctx, clients, createCmdFlags.token)
89+
if err != nil {
90+
return err
91+
}
92+
93+
domain := createCmdFlags.domain
94+
if domain == "" {
95+
domain = slugFromsandboxName(createCmdFlags.name)
96+
}
97+
98+
archiveDate, err := ttlToArchiveDate(createCmdFlags.ttl)
99+
if err != nil {
100+
return err
101+
}
102+
103+
result, err := clients.API().CreateSandbox(ctx, token,
104+
createCmdFlags.name,
105+
domain,
106+
createCmdFlags.password,
107+
createCmdFlags.locale,
108+
createCmdFlags.owningOrgID,
109+
createCmdFlags.template,
110+
createCmdFlags.eventCode,
111+
archiveDate,
112+
)
113+
if err != nil {
114+
return err
115+
}
116+
117+
switch createCmdFlags.output {
118+
case "json":
119+
encoder := json.NewEncoder(clients.IO.WriteOut())
120+
encoder.SetIndent("", " ")
121+
if err := encoder.Encode(result); err != nil {
122+
return err
123+
}
124+
default:
125+
printCreateSuccess(cmd, clients, result)
126+
}
127+
128+
if createCmdFlags.autoLogin && result.URL != "" {
129+
clients.Browser().OpenURL(result.URL)
130+
}
131+
132+
return nil
133+
}
134+
135+
const maxTTL = 180 * 24 * time.Hour // 6 months
136+
137+
// ttlToArchiveDate parses a TTL string (e.g., "24h", "1d", "7d") and returns the Unix epoch
138+
// when the sandbox will be archived. Returns 0 if ttl is empty (no archiving). Supports
139+
// Go duration format (h, m, s) and "Nd" for days. TTL cannot exceed 6 months.
140+
func ttlToArchiveDate(ttl string) (int64, error) {
141+
if ttl == "" {
142+
return 0, nil
143+
}
144+
var d time.Duration
145+
if strings.HasSuffix(strings.ToLower(ttl), "d") {
146+
numStr := strings.TrimSuffix(strings.ToLower(ttl), "d")
147+
n, err := strconv.Atoi(numStr)
148+
if err != nil {
149+
return 0, slackerror.New(slackerror.ErrInvalidArguments).
150+
WithMessage("Invalid TTL: %q", ttl).
151+
WithRemediation("Use a duration like 2h, 1d, or 7d")
152+
}
153+
d = time.Duration(n) * 24 * time.Hour
154+
} else {
155+
var err error
156+
d, err = time.ParseDuration(ttl)
157+
if err != nil {
158+
return 0, slackerror.New(slackerror.ErrInvalidArguments).
159+
WithMessage("Invalid TTL: %q", ttl).
160+
WithRemediation("Use a duration like 2h, 1d, or 7d")
161+
}
162+
}
163+
if d > maxTTL {
164+
return 0, slackerror.New(slackerror.ErrInvalidArguments).
165+
WithMessage("TTL cannot exceed 6 months").
166+
WithRemediation("Use a shorter duration (e.g., 2h, 1d, 7d)")
167+
}
168+
return time.Now().Add(d).Unix(), nil
169+
}
170+
171+
// slugFromsandboxName derives a domain-safe slug from org name (lowercase, alphanumeric + hyphens).
172+
func slugFromsandboxName(name string) string {
173+
var b []byte
174+
for _, r := range name {
175+
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
176+
b = append(b, byte(r))
177+
} else if r >= 'A' && r <= 'Z' {
178+
b = append(b, byte(r+32))
179+
} else if r == ' ' || r == '-' || r == '_' {
180+
if len(b) > 0 && b[len(b)-1] != '-' {
181+
b = append(b, '-')
182+
}
183+
}
184+
}
185+
// Trim leading/trailing hyphens
186+
for len(b) > 0 && b[0] == '-' {
187+
b = b[1:]
188+
}
189+
for len(b) > 0 && b[len(b)-1] == '-' {
190+
b = b[:len(b)-1]
191+
}
192+
if len(b) == 0 {
193+
return "sandbox"
194+
}
195+
return string(b)
196+
}
197+
198+
func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, result types.CreateSandboxResult) {
199+
ctx := cmd.Context()
200+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
201+
Emoji: "beach_with_umbrella",
202+
Text: " Sandbox Created",
203+
Secondary: []string{
204+
fmt.Sprintf("Team ID: %s", result.TeamID),
205+
fmt.Sprintf("User ID: %s", result.UserID),
206+
fmt.Sprintf("URL: %s", result.URL),
207+
},
208+
}))
209+
}

cmd/sandbox/delete.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
// Copyright 2022-2026 Salesforce, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package sandbox
16+
17+
import (
18+
"fmt"
19+
20+
"github.com/slackapi/slack-cli/internal/iostreams"
21+
"github.com/slackapi/slack-cli/internal/shared"
22+
"github.com/slackapi/slack-cli/internal/slackerror"
23+
"github.com/slackapi/slack-cli/internal/style"
24+
"github.com/spf13/cobra"
25+
)
26+
27+
type deleteFlags struct {
28+
sandboxID string
29+
force bool
30+
yes bool
31+
token string
32+
}
33+
34+
var deleteCmdFlags deleteFlags
35+
36+
func NewDeleteCommand(clients *shared.ClientFactory) *cobra.Command {
37+
cmd := &cobra.Command{
38+
Use: "delete [flags]",
39+
Short: "Delete a sandbox",
40+
Long: `Permanently delete a sandbox and all of its data`,
41+
Example: style.ExampleCommandsf([]style.ExampleCommand{
42+
{Command: "sandbox delete --sandbox E0123456", Meaning: "Delete a sandbox identified by its team ID"},
43+
}),
44+
Args: cobra.NoArgs,
45+
PreRunE: func(cmd *cobra.Command, args []string) error {
46+
return requireSandboxExperiment(clients)
47+
},
48+
RunE: func(cmd *cobra.Command, args []string) error {
49+
return runDeleteCommand(cmd, clients)
50+
},
51+
}
52+
53+
cmd.Flags().StringVar(&deleteCmdFlags.sandboxID, "sandbox", "", "Sandbox team ID to delete")
54+
cmd.Flags().BoolVar(&deleteCmdFlags.force, "force", false, "Skip confirmation prompt")
55+
cmd.Flags().StringVar(&deleteCmdFlags.token, "token", "", "Service account token for CI/CD authentication")
56+
cmd.MarkFlagRequired("sandbox")
57+
58+
return cmd
59+
}
60+
61+
func runDeleteCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
62+
ctx := cmd.Context()
63+
64+
token, auth, err := getSandboxTokenAndAuth(ctx, clients, deleteCmdFlags.token)
65+
if err != nil {
66+
return err
67+
}
68+
69+
skipConfirm := deleteCmdFlags.force || deleteCmdFlags.yes
70+
if !skipConfirm {
71+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
72+
Emoji: "warning",
73+
Text: style.Bold(" Danger zone"),
74+
Secondary: []string{
75+
fmt.Sprintf("Sandbox (%s) and all of its data will be permanently deleted", deleteCmdFlags.sandboxID),
76+
"This cannot be undone",
77+
},
78+
}))
79+
80+
proceed, err := clients.IO.ConfirmPrompt(ctx, "Are you sure you want to delete the sandbox?", false)
81+
if err != nil {
82+
if slackerror.Is(err, slackerror.ErrProcessInterrupted) {
83+
clients.IO.SetExitCode(iostreams.ExitCancel)
84+
}
85+
return err
86+
}
87+
if !proceed {
88+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
89+
Emoji: "thumbs_up",
90+
Text: "Deletion cancelled",
91+
}))
92+
return nil
93+
}
94+
}
95+
96+
if err := clients.API().DeleteSandbox(ctx, token, deleteCmdFlags.sandboxID); err != nil {
97+
return err
98+
}
99+
100+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
101+
Emoji: "white_check_mark",
102+
Text: "Sandbox deleted",
103+
Secondary: []string{
104+
"Sandbox " + deleteCmdFlags.sandboxID + " has been permanently deleted",
105+
},
106+
}))
107+
108+
err = printSandboxes(cmd, clients, token, auth)
109+
if err != nil {
110+
return err
111+
}
112+
113+
return nil
114+
}

0 commit comments

Comments
 (0)