Skip to content

Commit 26db1d1

Browse files
authored
Misc usability improvements for sandbox CLI (#318)
1 parent b0e8dcb commit 26db1d1

File tree

3 files changed

+100
-9
lines changed

3 files changed

+100
-9
lines changed

cmd/rwx/sandbox.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,33 @@ var sandboxStartCmd = &cobra.Command{
127127
var sandboxExecCmd = &cobra.Command{
128128
Use: "exec [config-file] -- <command>",
129129
Short: "Execute a command in a sandbox",
130-
Args: cobra.ArbitraryArgs,
130+
Long: `Execute a command in a persistent cloud sandbox environment.
131+
132+
OVERVIEW
133+
Sandboxes are isolated, reproducible environments running in RWX cloud
134+
infrastructure. They persist between commands, allowing you to run multiple
135+
commands against the same environment without rebuilding each time.
136+
137+
FILE SYNCING
138+
Before each command, local uncommitted changes are automatically synced to
139+
the sandbox via git patch. This includes staged and unstaged changes, but
140+
untracked files are not synced. You need to "git add" untracked files before
141+
running the command.
142+
Use --no-sync to skip this step if you want to run against the sandbox's
143+
original state.
144+
145+
Note: Git LFS files cannot be synced and will generate a warning.
146+
147+
CONFIG FILE
148+
The sandbox configuration (default: .rwx/sandbox.yml) defines:
149+
- Base image and dependencies
150+
- Git repository to clone
151+
- Any setup tasks that run before the sandbox becomes available
152+
153+
The config must include a task with "run: rwx-sandbox" which defines the
154+
sandbox entry point, and must be dependent on a task that uses git/clone.
155+
`,
156+
Args: cobra.ArbitraryArgs,
131157
PreRunE: func(cmd *cobra.Command, args []string) error {
132158
return requireAccessToken()
133159
},

internal/cli/sandbox_storage_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,30 @@ func TestSandboxStorage_LoadAndSave(t *testing.T) {
348348
require.Contains(t, err.Error(), "unable to parse")
349349
})
350350
}
351+
352+
func TestSandboxTitle(t *testing.T) {
353+
t.Run("creates title from project name and branch", func(t *testing.T) {
354+
title := cli.SandboxTitle("/home/user/my-project", "main", ".rwx/sandbox.yml")
355+
require.Equal(t, "Sandbox: my-project (main)", title)
356+
})
357+
358+
t.Run("uses 'detached' when branch is empty", func(t *testing.T) {
359+
title := cli.SandboxTitle("/home/user/my-project", "", ".rwx/sandbox.yml")
360+
require.Equal(t, "Sandbox: my-project (detached)", title)
361+
})
362+
363+
t.Run("includes non-default config file", func(t *testing.T) {
364+
title := cli.SandboxTitle("/home/user/my-project", "feature/test", ".rwx/custom.yml")
365+
require.Equal(t, "Sandbox: my-project (feature/test) [.rwx/custom.yml]", title)
366+
})
367+
368+
t.Run("excludes default config file", func(t *testing.T) {
369+
title := cli.SandboxTitle("/home/user/my-project", "develop", ".rwx/sandbox.yml")
370+
require.Equal(t, "Sandbox: my-project (develop)", title)
371+
})
372+
373+
t.Run("handles empty config file", func(t *testing.T) {
374+
title := cli.SandboxTitle("/home/user/my-project", "main", "")
375+
require.Equal(t, "Sandbox: my-project (main)", title)
376+
})
377+
}

internal/cli/service_sandbox.go

Lines changed: 46 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"strings"
89
"time"
910

@@ -228,10 +229,14 @@ func (s Service) StartSandbox(cfg StartSandboxConfig) (*StartSandboxResult, erro
228229
finishSpinner = SpinUntilDone("Starting sandbox...", s.StdoutIsTTY, s.Stdout)
229230
}
230231

232+
// Construct a descriptive title for the sandbox run
233+
title := SandboxTitle(cwd, branch, cfg.ConfigFile)
234+
231235
runResult, err := s.InitiateRun(InitiateRunConfig{
232236
MintFilePath: cfg.ConfigFile,
233237
RwxDirectory: cfg.RwxDirectory,
234238
Json: cfg.Json,
239+
Title: title,
235240
})
236241

237242
if err != nil {
@@ -599,14 +604,36 @@ func (s Service) ResetSandbox(cfg ResetSandboxConfig) (*ResetSandboxResult, erro
599604
// Helper methods
600605

601606
func (s Service) waitForSandboxReady(runID string, jsonMode bool) (*api.SandboxConnectionInfo, error) {
607+
// Check once before showing spinner - sandbox may already be ready
608+
connInfo, err := s.APIClient.GetSandboxConnectionInfo(runID)
609+
if err != nil {
610+
return nil, errors.Wrap(err, "unable to get sandbox connection info")
611+
}
612+
613+
if connInfo.Sandboxable {
614+
return &connInfo, nil
615+
}
616+
617+
if connInfo.Polling.Completed {
618+
return nil, fmt.Errorf("Sandbox run '%s' completed before becoming ready", runID)
619+
}
620+
621+
// Sandbox not ready yet - start spinner and poll
602622
var stopSpinner func()
603623
if !jsonMode {
604624
stopSpinner = Spin("Waiting for sandbox to be ready...", s.StdoutIsTTY, s.Stdout)
605625
defer stopSpinner()
606626
}
607627

608628
for {
609-
connInfo, err := s.APIClient.GetSandboxConnectionInfo(runID)
629+
// Use backoff from server, or default to 2 seconds
630+
backoffMs := 2000
631+
if connInfo.Polling.BackoffMs != nil {
632+
backoffMs = *connInfo.Polling.BackoffMs
633+
}
634+
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
635+
636+
connInfo, err = s.APIClient.GetSandboxConnectionInfo(runID)
610637
if err != nil {
611638
return nil, errors.Wrap(err, "unable to get sandbox connection info")
612639
}
@@ -618,13 +645,6 @@ func (s Service) waitForSandboxReady(runID string, jsonMode bool) (*api.SandboxC
618645
if connInfo.Polling.Completed {
619646
return nil, fmt.Errorf("Sandbox run '%s' completed before becoming ready", runID)
620647
}
621-
622-
// Use backoff from server, or default to 2 seconds
623-
backoffMs := 2000
624-
if connInfo.Polling.BackoffMs != nil {
625-
backoffMs = *connInfo.Polling.BackoffMs
626-
}
627-
time.Sleep(time.Duration(backoffMs) * time.Millisecond)
628648
}
629649
}
630650

@@ -657,6 +677,24 @@ func (s Service) connectSSH(connInfo *api.SandboxConnectionInfo) error {
657677
return nil
658678
}
659679

680+
// SandboxTitle constructs a descriptive title for sandbox runs using the
681+
// project directory name, branch, and config file (if non-default).
682+
func SandboxTitle(cwd, branch, configFile string) string {
683+
project := filepath.Base(cwd)
684+
if branch == "" {
685+
branch = "detached"
686+
}
687+
688+
title := fmt.Sprintf("Sandbox: %s (%s)", project, branch)
689+
690+
// Include config file if it's not the default
691+
if configFile != "" && configFile != ".rwx/sandbox.yml" {
692+
title = fmt.Sprintf("%s [%s]", title, configFile)
693+
}
694+
695+
return title
696+
}
697+
660698
func (s Service) syncChangesToSandbox(jsonMode bool) error {
661699
patch, lfsFiles, err := s.GitClient.GeneratePatch(nil)
662700
if err != nil {

0 commit comments

Comments
 (0)