Skip to content
Merged
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
29 changes: 25 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,10 +363,11 @@ under `packages/orca-plugin`.

**When to use `exec`, `shell`, `process`, and PTY:**

Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox shell` when you want an immediate interactive terminal and do not need to reconnect later. Use `sandbox process` when the command should be manageable after it starts — list it, reconnect to output, send input, wait for it, signal it, or stop it. Add `--pty`/`--tty`/`-t` to `process run` or `process start` when the managed command needs terminal behavior.
Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox run` when you want a fresh devbox sandbox that runs a Docker image for you. Use `sandbox shell` when you want an immediate interactive terminal and do not need to reconnect later. Use `sandbox process` when the command should be manageable after it starts — list it, reconnect to output, send input, wait for it, signal it, or stop it. Add `--pty`/`--tty`/`-t` to `process run` or `process start` when the managed command needs terminal behavior.

| Command | Description |
| -------------------------------------------- | ------------------------------------------------------------ |
| `createos sandbox run <image> [args…]` | Create a devbox sandbox and run a Docker image inside it |
| `createos sandbox process run <sb> -- <cmd>` | Run a managed command, stream output, and return its exit code |
| `createos sandbox process start <sb> -- <cmd>` | Start a managed command and print its process ID |
| `createos sandbox process shell <sb>` | Start a persistent shell session that can be reattached |
Expand Down Expand Up @@ -395,9 +396,9 @@ Interactive attach without a process ID shows running managed processes. Pick a
| `createos sandbox disk rm <name\|id>` | Delete a disk (auto-detaches first) |
| `createos sandbox network create <name>` | Create a private network |
| `createos sandbox network ls` | List your networks |
| `createos sandbox network show <name\|id>` | Show a network and its attached sandboxes |
| `createos sandbox network attach <net> <sb>` | Add a sandbox to a network |
| `createos sandbox network detach <net> <sb>` | Remove a sandbox from a network |
| `createos sandbox network show <name\|id>` | Show a network and its attached sandbox members |
| `createos sandbox network attach <net> <sb\|device>` | Add a sandbox or device to a network |
| `createos sandbox network detach <net> <sb\|device>` | Remove a sandbox or device from a network |
| `createos sandbox network rm <name\|id>` | Delete a network (auto-detaches first) |
| `createos sandbox firewall show <sandbox>` | Show what the sandbox is allowed to reach |
| `createos sandbox firewall set <sb> <host…>` | Replace the outbound allowlist |
Expand Down Expand Up @@ -568,6 +569,24 @@ createos sandbox rm my-box --force
createos sandbox shapes
createos sandbox rootfs

# Sandbox run
createos sandbox run nginx --local 8080 --remote 80 --rm
createos sandbox run postgres \
--disk pg-data,/data:/var/lib/postgresql/data \
--local 5432 --remote 5432 \
--env POSTGRES_PASSWORD=secret \
--rm
createos sandbox run my-app:local --push-local --env NODE_ENV=development --rm
createos sandbox run nginx \
--sync ./site,/workspace:/usr/share/nginx/html \
--local 8080 --remote 80 \
--rm

# `--disk <disk>,<sandbox-path>:<container-path>` attaches the disk at
# <sandbox-path> in the sandbox, then mounts that path into the Docker container.
# `--sync <local-dir>,<sandbox-path>:<container-path>` syncs a local directory to
# the sandbox first, then mounts that sandbox path into the Docker container.

# Sandbox sync
createos sandbox sync my-box --local ~/work/project --remote /root/work
createos sandbox sync my-box --exclude '*.log' --exclude node_modules # skip files (repeatable)
Expand All @@ -587,7 +606,9 @@ createos sandbox disk rm my-data --yes
createos sandbox network create my-net
createos sandbox network ls
createos sandbox network attach my-net my-box
createos sandbox network attach my-net <device-id>
createos sandbox network detach my-net my-box --yes
createos sandbox network detach my-net <device-id> --yes
createos sandbox network rm my-net --yes

# Sandbox firewall
Expand Down
2 changes: 1 addition & 1 deletion cmd/sandbox/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,7 @@ func preflightVPN(c *cli.Context, client *api.SandboxClient, sandboxID string) e
pterm.Warning.Println("this sandbox and your device aren't in the same network yet.")
pterm.Println()
pterm.Println(" Add the sandbox to a network your device is in:")
pterm.Println(" createos sandbox network attach " + sandboxID + " <network>")
pterm.Println(" createos sandbox network attach <network> " + sandboxID)
pterm.Println()
pterm.Println(" Or add the device to a network the sandbox is in:")
pterm.Println(" createos sandbox devices attach <network>")
Expand Down
129 changes: 122 additions & 7 deletions cmd/sandbox/network.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,9 @@ func newNetworkAttachCommand() *cli.Command {
}

// isDeviceRef reports whether ref looks like a device id (dev-…) — used
// so `network attach dev-… <net>` routes to the device-attach API
// instead of the sandbox one. Plain prefix sniff: device ids are minted
// with this prefix and nothing else legitimately starts with it.
// so `network attach <net> dev-…` routes to the device-attach API instead
// of the sandbox one. Plain prefix sniff: device ids are minted with this
// prefix and nothing else legitimately starts with it.
func isDeviceRef(ref string) bool {
return strings.HasPrefix(ref, "dev-") || strings.HasPrefix(ref, "dev_")
}
Expand Down Expand Up @@ -370,6 +370,9 @@ func runNetworkAttach(c *cli.Context) error {
}
ref = picked
}
if looksLikeSandboxRef(netRef) && !looksLikeSandboxRef(ref) && !isDeviceRef(ref) {
return fmt.Errorf("network attach expects <network> <sandbox|device>\n\n Did you mean?\n createos sandbox network attach %s %s", ref, netRef)
}
if isDeviceRef(ref) {
if err := client.AttachDeviceToNetwork(c.Context, ref, netRef); err != nil {
return err
Expand All @@ -390,6 +393,10 @@ func runNetworkAttach(c *cli.Context) error {
return nil
}

func looksLikeSandboxRef(ref string) bool {
return strings.HasPrefix(ref, "sb-") || strings.HasPrefix(ref, "sb_")
}

// ── detach ───────────────────────────────────────────────────────

func newNetworkDetachCommand() *cli.Command {
Expand Down Expand Up @@ -436,7 +443,7 @@ func runNetworkDetach(c *cli.Context) error {
if !tty {
return fmt.Errorf("usage: createos sandbox network detach <network> <sandbox|device>")
}
picked, err := pickEndpoint(c, client, "Detach what?")
picked, err := pickNetworkMemberEndpoint(c, client, netRef, "Detach what?")
if err != nil {
return err
}
Expand Down Expand Up @@ -492,8 +499,8 @@ func runNetworkDetach(c *cli.Context) error {

// pickEndpoint shows a single-select picker that lists BOTH the caller's
// running sandboxes and registered devices, returning whichever ref the
// user picks (sb-… or dev-…). Used by `network attach` / `network detach`
// to support attaching devices alongside sandboxes in interactive mode.
// user picks (sb-… or dev-…). Used by `network attach` to support attaching
// devices alongside sandboxes in interactive mode.
func pickEndpoint(c *cli.Context, client *api.SandboxClient, title string) (string, error) {
// Sandboxes (running only — same filter as the old picker).
sbs, _, err := client.ListSandboxes(c.Context, api.ListSandboxesOpts{Limit: 200, Status: "running"})
Expand Down Expand Up @@ -538,6 +545,81 @@ func pickEndpoint(c *cli.Context, client *api.SandboxClient, title string) (stri
return refByOpt[picked], nil
}

// pickNetworkMemberEndpoint is the detach-specific picker. Unlike attach,
// detach should only offer endpoints already attached to the selected network.
func pickNetworkMemberEndpoint(c *cli.Context, client *api.SandboxClient, netRef, title string) (string, error) {
n, err := client.GetNetwork(c.Context, netRef)
if err != nil {
return "", err
}

devs, err := client.ListDevices(c.Context)
if err != nil {
devs = nil
}
deviceNetworkRefs := make(map[string][]api.DeviceNetworkAttachmentView, len(devs))
for _, d := range devs {
nets, nerr := client.ListDeviceNetworks(c.Context, d.ID)
if nerr != nil {
continue
}
deviceNetworkRefs[d.ID] = nets
}

options, refByOpt := networkMemberEndpointOptions(n, devs, deviceNetworkRefs)
if len(options) == 0 {
fmt.Printf("Network %s has no attached sandboxes or devices.\n", n.Name)
return "", nil
}
picked, err := pterm.DefaultInteractiveSelect.
WithOptions(options).
WithDefaultText(title).
Show()
if err != nil {
return "", fmt.Errorf("could not read your selection: %w", err)
}
return refByOpt[picked], nil
}

func networkMemberEndpointOptions(n *api.SandboxNetwork, devs []api.DeviceView, deviceNetworks map[string][]api.DeviceNetworkAttachmentView) ([]string, map[string]string) {
options := make([]string, 0, len(n.Members)+len(devs))
refByOpt := make(map[string]string, len(n.Members)+len(devs))
for _, m := range n.Members {
label := m.SandboxID
if m.Name != "" {
label = m.Name
}
details := fmt.Sprintf("id: %s", m.SandboxID)
if m.Status != "" {
details += ", status: " + m.Status
}
if m.IP != "" {
details += ", ip: " + m.IP
}
opt := fmt.Sprintf("sandbox: %s (%s)", label, details)
options = append(options, opt)
refByOpt[opt] = m.SandboxID
}
for _, d := range devs {
if !deviceAttachedToNetwork(n, deviceNetworks[d.ID]) {
continue
}
opt := fmt.Sprintf("device: %s (%s, id: %s)", d.Name, d.ClientIP, d.ID)
options = append(options, opt)
refByOpt[opt] = d.ID
}
return options, refByOpt
}

func deviceAttachedToNetwork(n *api.SandboxNetwork, attached []api.DeviceNetworkAttachmentView) bool {
for _, a := range attached {
if a.NetworkID == n.ID || a.NetworkName == n.Name {
return true
}
}
return false
}

// pickNetwork renders a single-select picker over the caller's networks
// and returns the picked NAME (the server accepts it wherever an ID
// works). Returns "" when the user cancels.
Expand All @@ -551,10 +633,11 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin
pterm.Println(pterm.Gray(" Create one with: createos sandbox network create <name>"))
return "", nil
}
deviceCounts := countDevicesByNetwork(c, client)
options := make([]string, 0, len(nets))
byOpt := make(map[string]string, len(nets))
for _, n := range nets {
opt := fmt.Sprintf("%s (sandboxes: %d, id: %s)", n.Name, n.MemberCount, n.ID)
opt := networkPickerOption(n, deviceCounts)
options = append(options, opt)
byOpt[opt] = n.Name
}
Expand All @@ -567,3 +650,35 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin
}
return byOpt[picked], nil
}

func countDevicesByNetwork(c *cli.Context, client *api.SandboxClient) map[string]int {
counts := make(map[string]int)
devs, err := client.ListDevices(c.Context)
if err != nil {
return counts
}
for _, d := range devs {
nets, nerr := client.ListDeviceNetworks(c.Context, d.ID)
if nerr != nil {
continue
}
for _, n := range nets {
if n.NetworkID != "" {
counts[n.NetworkID]++
continue
}
if n.NetworkName != "" {
counts[n.NetworkName]++
}
}
}
return counts
}

func networkPickerOption(n api.SandboxNetwork, deviceCounts map[string]int) string {
deviceCount := deviceCounts[n.ID]
if deviceCount == 0 {
deviceCount = deviceCounts[n.Name]
}
return fmt.Sprintf("%s (sandboxes: %d, devices: %d, id: %s)", n.Name, n.MemberCount, deviceCount, n.ID)
}
80 changes: 80 additions & 0 deletions cmd/sandbox/network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package sandbox

import (
"reflect"
"testing"

"github.com/NodeOps-app/createos-cli/internal/api"
)

func TestLooksLikeSandboxRef(t *testing.T) {
t.Parallel()

tests := []struct {
ref string
want bool
}{
{ref: "sb-01m10y7j0qgphydk8awvmnbza3", want: true},
{ref: "sb_01m10y7j0qgphydk8awvmnbza3", want: true},
{ref: "bhautikin", want: false},
{ref: "dev-01m10y7j0qgphydk8awvmnbza3", want: false},
}
for _, tt := range tests {
if got := looksLikeSandboxRef(tt.ref); got != tt.want {
t.Fatalf("looksLikeSandboxRef(%q) = %v, want %v", tt.ref, got, tt.want)
}
}
}

func TestNetworkMemberEndpointOptionsOnlyIncludesAttachedMembers(t *testing.T) {
t.Parallel()

network := &api.SandboxNetwork{
ID: "net-123",
Name: "bhautikin",
Members: []api.SandboxNetworkMember{{
SandboxID: "sb-1",
Name: "app",
Status: "running",
IP: "10.0.0.4",
}},
}
devs := []api.DeviceView{
{ID: "dev-1", Name: "laptop", ClientIP: "100.64.0.8"},
{ID: "dev-2", Name: "desktop", ClientIP: "100.64.0.9"},
}
deviceNetworks := map[string][]api.DeviceNetworkAttachmentView{
"dev-1": {{NetworkID: "net-123", NetworkName: "bhautikin"}},
"dev-2": {{NetworkID: "net-other", NetworkName: "other"}},
}

options, refs := networkMemberEndpointOptions(network, devs, deviceNetworks)
wantOptions := []string{
"sandbox: app (id: sb-1, status: running, ip: 10.0.0.4)",
"device: laptop (100.64.0.8, id: dev-1)",
}
if !reflect.DeepEqual(options, wantOptions) {
t.Fatalf("options = %#v, want %#v", options, wantOptions)
}
if refs[options[0]] != "sb-1" {
t.Fatalf("sandbox ref = %q", refs[options[0]])
}
if refs[options[1]] != "dev-1" {
t.Fatalf("device ref = %q", refs[options[1]])
}
}

func TestDeviceAttachedToNetworkMatchesNameOrID(t *testing.T) {
t.Parallel()

network := &api.SandboxNetwork{ID: "net-123", Name: "bhautikin"}
if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-123"}}) {
t.Fatal("expected ID match")
}
if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkName: "bhautikin"}}) {
t.Fatal("expected name match")
}
if deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-other", NetworkName: "other"}}) {
t.Fatal("unexpected match")
}
}
Loading