Skip to content

Commit 55a4069

Browse files
authored
feat(experiment): create and delete developer sandboxes (#389)
1 parent 7e4a60f commit 55a4069

File tree

13 files changed

+1476
-24
lines changed

13 files changed

+1476
-24
lines changed

cmd/sandbox/create.go

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
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+
"strconv"
20+
"strings"
21+
"time"
22+
"unicode"
23+
24+
"github.com/slackapi/slack-cli/internal/iostreams"
25+
"github.com/slackapi/slack-cli/internal/shared"
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+
archiveTTL string // TTL duration, e.g. 1d, 2w, 3mo
40+
archiveDate string // explicit date yyyy-mm-dd
41+
partner bool
42+
}
43+
44+
var createCmdFlags createFlags
45+
46+
// templateNameToID maps user-friendly template names to integer IDs
47+
var templateNameToID = map[string]int{
48+
"default": 1, // The default template
49+
"empty": 0, // The sandbox will be empty if the template param is not set
50+
}
51+
52+
func NewCreateCommand(clients *shared.ClientFactory) *cobra.Command {
53+
cmd := &cobra.Command{
54+
Use: "create [flags]",
55+
Short: "Create a developer sandbox",
56+
Long: `Create a new Slack developer sandbox`,
57+
Example: style.ExampleCommandsf([]style.ExampleCommand{
58+
{Command: "sandbox create --name test-box --password mypass", Meaning: "Create a sandbox named test-box"},
59+
{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"},
60+
{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"},
61+
}),
62+
Args: cobra.NoArgs,
63+
PreRunE: func(cmd *cobra.Command, args []string) error {
64+
return requireSandboxExperiment(clients)
65+
},
66+
RunE: func(cmd *cobra.Command, args []string) error {
67+
return runCreateCommand(cmd, clients)
68+
},
69+
}
70+
71+
cmd.Flags().StringVar(&createCmdFlags.name, "name", "", "Organization name for the new sandbox")
72+
cmd.Flags().StringVar(&createCmdFlags.domain, "domain", "", "Team domain. Derived from org name if not provided")
73+
cmd.Flags().StringVar(&createCmdFlags.password, "password", "", "Password used to log into the sandbox")
74+
cmd.Flags().StringVar(&createCmdFlags.locale, "locale", "", "Locale (eg. en-us, languageCode-countryCode)")
75+
cmd.Flags().StringVar(&createCmdFlags.template, "template", "", "Template with sample data to apply to the sandbox (options: default, empty)")
76+
cmd.Flags().StringVar(&createCmdFlags.eventCode, "event-code", "", "Event code for the sandbox")
77+
cmd.Flags().StringVar(&createCmdFlags.archiveTTL, "archive-ttl", "", "Time-to-live duration (eg. 1d, 2w, 3mo). Cannot be used with --archive-date")
78+
cmd.Flags().StringVar(&createCmdFlags.archiveDate, "archive-date", "", "Explicit archive date in yyyy-mm-dd format. Cannot be used with --archive-ttl")
79+
cmd.Flags().BoolVar(&createCmdFlags.partner, "partner", false, "Developers who are part of the Partner program can create partner sandboxes")
80+
81+
// If one's developer account is managed by multiple Production Slack teams, one of those team IDs must be provided in the command
82+
cmd.Flags().StringVar(&createCmdFlags.owningOrgID, "owning-org-id", "", "Enterprise team ID that manages your developer account, if applicable")
83+
84+
return cmd
85+
}
86+
87+
func runCreateCommand(cmd *cobra.Command, clients *shared.ClientFactory) error {
88+
ctx := cmd.Context()
89+
90+
auth, err := getSandboxAuth(ctx, clients)
91+
if err != nil {
92+
return err
93+
}
94+
95+
name := createCmdFlags.name
96+
if name == "" {
97+
name, err = clients.IO.InputPrompt(
98+
ctx,
99+
"Enter a name for the sandbox",
100+
iostreams.InputPromptConfig{
101+
Required: true,
102+
},
103+
)
104+
if err != nil {
105+
return err
106+
}
107+
}
108+
109+
password := createCmdFlags.password
110+
if password == "" {
111+
password, err = clients.IO.InputPrompt(
112+
ctx,
113+
"Enter a password for the sandbox",
114+
iostreams.InputPromptConfig{
115+
Required: true,
116+
},
117+
)
118+
if err != nil {
119+
return err
120+
}
121+
}
122+
123+
domain := createCmdFlags.domain
124+
if domain == "" {
125+
var err error
126+
domain, err = domainFromName(name)
127+
if err != nil {
128+
return err
129+
}
130+
}
131+
132+
if createCmdFlags.archiveTTL != "" && createCmdFlags.archiveDate != "" {
133+
return slackerror.New(slackerror.ErrInvalidArguments).
134+
WithMessage("Cannot use both --archive-ttl and --archive-date")
135+
}
136+
137+
archiveEpochDatetime := int64(0)
138+
if createCmdFlags.archiveTTL != "" {
139+
archiveEpochDatetime, err = getEpochFromTTL(createCmdFlags.archiveTTL)
140+
if err != nil {
141+
return err
142+
}
143+
} else if createCmdFlags.archiveDate != "" {
144+
archiveEpochDatetime, err = getEpochFromDate(createCmdFlags.archiveDate)
145+
if err != nil {
146+
return err
147+
}
148+
}
149+
150+
templateID, err := getTemplateID(createCmdFlags.template)
151+
if err != nil {
152+
return err
153+
}
154+
155+
teamID, sandboxURL, err := clients.API().CreateSandbox(ctx, auth.Token,
156+
name,
157+
domain,
158+
password,
159+
createCmdFlags.locale,
160+
createCmdFlags.owningOrgID,
161+
templateID,
162+
createCmdFlags.eventCode,
163+
archiveEpochDatetime,
164+
createCmdFlags.partner,
165+
)
166+
if err != nil {
167+
return err
168+
}
169+
170+
printCreateSuccess(cmd, clients, teamID, sandboxURL)
171+
172+
return nil
173+
}
174+
175+
// getEpochFromTTL parses a time-to-live string (e.g., "1d", "2w", "3mo") and returns the Unix epoch
176+
// when the sandbox will be archived. Supports days (d), weeks (w), and months (mo).
177+
func getEpochFromTTL(ttl string) (int64, error) {
178+
lower := strings.TrimSpace(strings.ToLower(ttl))
179+
if lower == "" {
180+
return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL)
181+
}
182+
183+
var target time.Time
184+
now := time.Now()
185+
186+
switch {
187+
case strings.HasSuffix(lower, "d"):
188+
n, err := strconv.Atoi(strings.TrimSuffix(lower, "d"))
189+
if err != nil || n < 1 {
190+
return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL)
191+
}
192+
target = now.AddDate(0, 0, n)
193+
case strings.HasSuffix(lower, "w"):
194+
n, err := strconv.Atoi(strings.TrimSuffix(lower, "w"))
195+
if err != nil || n < 1 {
196+
return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL)
197+
}
198+
target = now.AddDate(0, 0, n*7)
199+
case strings.HasSuffix(lower, "mo"):
200+
n, err := strconv.Atoi(strings.TrimSuffix(lower, "mo"))
201+
if err != nil || n < 1 {
202+
return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL)
203+
}
204+
target = now.AddDate(0, n, 0)
205+
default:
206+
return 0, slackerror.New(slackerror.ErrInvalidSandboxArchiveTTL)
207+
}
208+
209+
return target.Unix(), nil
210+
}
211+
212+
// getEpochFromDate parses a date in yyyy-mm-dd format and returns the Unix epoch at the start of that day (UTC)
213+
func getEpochFromDate(dateStr string) (int64, error) {
214+
dateFormat := "2006-01-02"
215+
t, err := time.ParseInLocation(dateFormat, dateStr, time.UTC)
216+
if err != nil {
217+
return 0, slackerror.New(slackerror.ErrInvalidArguments).
218+
WithMessage("Invalid archive date: %q", dateStr).
219+
WithRemediation("Use yyyy-mm-dd format")
220+
}
221+
now := time.Now().UTC()
222+
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
223+
if t.Before(today) {
224+
return 0, slackerror.New(slackerror.ErrInvalidArguments).
225+
WithMessage("Archive date must be in the future")
226+
}
227+
return t.Unix(), nil
228+
}
229+
230+
// domainFromName derives domain-safe text from the name of the sandbox (lowercase, alphanumeric + hyphens).
231+
func domainFromName(name string) (string, error) {
232+
name = strings.ToLower(name)
233+
name = strings.ReplaceAll(name, " ", "-")
234+
name = strings.ReplaceAll(name, "_", "-")
235+
var domain []byte
236+
for _, r := range name {
237+
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
238+
domain = append(domain, byte(r))
239+
}
240+
}
241+
domain = []byte(strings.Trim(string(domain), "-"))
242+
if len(domain) == 0 {
243+
return "", slackerror.New(slackerror.ErrInvalidArguments).
244+
WithMessage("Provide a valid domain name with the --domain flag")
245+
}
246+
return string(domain), nil
247+
}
248+
249+
// getTemplateID converts a template string to an integer ID
250+
func getTemplateID(template string) (int, error) {
251+
if template == "" {
252+
return 0, nil
253+
}
254+
key := strings.ToLower(strings.TrimSpace(template))
255+
// If the provided string is present in the map, return the ID
256+
if id, ok := templateNameToID[key]; ok {
257+
return id, nil
258+
}
259+
// We also accept an integer passed directly via the flag
260+
if id, err := strconv.Atoi(key); err == nil {
261+
return id, nil
262+
}
263+
return 0, slackerror.New(slackerror.ErrInvalidSandboxTemplateID).
264+
WithMessage("Invalid template: %q", template)
265+
}
266+
267+
func printCreateSuccess(cmd *cobra.Command, clients *shared.ClientFactory, teamID, url string) {
268+
ctx := cmd.Context()
269+
clients.IO.PrintInfo(ctx, false, "\n%s", style.Sectionf(style.TextSection{
270+
Emoji: "beach_with_umbrella",
271+
Text: "Sandbox Created",
272+
Secondary: []string{
273+
fmt.Sprintf("Team ID: %s", teamID),
274+
fmt.Sprintf("URL: %s", url),
275+
},
276+
}))
277+
clients.IO.PrintInfo(ctx, false, "Manage this sandbox from the CLI or visit\n%s", style.Secondary("https://api.slack.com/developer-program/sandboxes"))
278+
}

0 commit comments

Comments
 (0)