Sitelet https://github.com/NodeOps-app/createos-cli/commit/d7ac16f9d555f100d354a95f0639d2fcecbc53b3
Skip to content

Commit d7ac16f

Browse files
feat: image run using - sandbox run (#83)
* feat(sandbox): add docker-style run command * fix(sandbox): clarify network attach order * fix(sandbox): limit network detach picker to members * fix(sandbox): show device counts in network picker * fix(sandbox): address run command lint * docs: add sandbox run usage
1 parent 8c1f7ac commit d7ac16f

8 files changed

Lines changed: 1538 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -363,10 +363,11 @@ under `packages/orca-plugin`.
363363

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

366-
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.
366+
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.
367367

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

572+
# Sandbox run
573+
createos sandbox run nginx --local 8080 --remote 80 --rm
574+
createos sandbox run postgres \
575+
--disk pg-data,/data:/var/lib/postgresql/data \
576+
--local 5432 --remote 5432 \
577+
--env POSTGRES_PASSWORD=secret \
578+
--rm
579+
createos sandbox run my-app:local --push-local --env NODE_ENV=development --rm
580+
createos sandbox run nginx \
581+
--sync ./site,/workspace:/usr/share/nginx/html \
582+
--local 8080 --remote 80 \
583+
--rm
584+
585+
# `--disk <disk>,<sandbox-path>:<container-path>` attaches the disk at
586+
# <sandbox-path> in the sandbox, then mounts that path into the Docker container.
587+
# `--sync <local-dir>,<sandbox-path>:<container-path>` syncs a local directory to
588+
# the sandbox first, then mounts that sandbox path into the Docker container.
589+
571590
# Sandbox sync
572591
createos sandbox sync my-box --local ~/work/project --remote /root/work
573592
createos sandbox sync my-box --exclude '*.log' --exclude node_modules # skip files (repeatable)
@@ -587,7 +606,9 @@ createos sandbox disk rm my-data --yes
587606
createos sandbox network create my-net
588607
createos sandbox network ls
589608
createos sandbox network attach my-net my-box
609+
createos sandbox network attach my-net <device-id>
590610
createos sandbox network detach my-net my-box --yes
611+
createos sandbox network detach my-net <device-id> --yes
591612
createos sandbox network rm my-net --yes
592613

593614
# Sandbox firewall

‎cmd/sandbox/editor.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,7 @@ func preflightVPN(c *cli.Context, client *api.SandboxClient, sandboxID string) e
684684
pterm.Warning.Println("this sandbox and your device aren't in the same network yet.")
685685
pterm.Println()
686686
pterm.Println(" Add the sandbox to a network your device is in:")
687-
pterm.Println(" createos sandbox network attach " + sandboxID + " <network>")
687+
pterm.Println(" createos sandbox network attach <network> " + sandboxID)
688688
pterm.Println()
689689
pterm.Println(" Or add the device to a network the sandbox is in:")
690690
pterm.Println(" createos sandbox devices attach <network>")

‎cmd/sandbox/network.go‎

Lines changed: 122 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,9 @@ func newNetworkAttachCommand() *cli.Command {
321321
}
322322

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

396+
func looksLikeSandboxRef(ref string) bool {
397+
return strings.HasPrefix(ref, "sb-") || strings.HasPrefix(ref, "sb_")
398+
}
399+
393400
// ── detach ───────────────────────────────────────────────────────
394401

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

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

548+
// pickNetworkMemberEndpoint is the detach-specific picker. Unlike attach,
549+
// detach should only offer endpoints already attached to the selected network.
550+
func pickNetworkMemberEndpoint(c *cli.Context, client *api.SandboxClient, netRef, title string) (string, error) {
551+
n, err := client.GetNetwork(c.Context, netRef)
552+
if err != nil {
553+
return "", err
554+
}
555+
556+
devs, err := client.ListDevices(c.Context)
557+
if err != nil {
558+
devs = nil
559+
}
560+
deviceNetworkRefs := make(map[string][]api.DeviceNetworkAttachmentView, len(devs))
561+
for _, d := range devs {
562+
nets, nerr := client.ListDeviceNetworks(c.Context, d.ID)
563+
if nerr != nil {
564+
continue
565+
}
566+
deviceNetworkRefs[d.ID] = nets
567+
}
568+
569+
options, refByOpt := networkMemberEndpointOptions(n, devs, deviceNetworkRefs)
570+
if len(options) == 0 {
571+
fmt.Printf("Network %s has no attached sandboxes or devices.\n", n.Name)
572+
return "", nil
573+
}
574+
picked, err := pterm.DefaultInteractiveSelect.
575+
WithOptions(options).
576+
WithDefaultText(title).
577+
Show()
578+
if err != nil {
579+
return "", fmt.Errorf("could not read your selection: %w", err)
580+
}
581+
return refByOpt[picked], nil
582+
}
583+
584+
func networkMemberEndpointOptions(n *api.SandboxNetwork, devs []api.DeviceView, deviceNetworks map[string][]api.DeviceNetworkAttachmentView) ([]string, map[string]string) {
585+
options := make([]string, 0, len(n.Members)+len(devs))
586+
refByOpt := make(map[string]string, len(n.Members)+len(devs))
587+
for _, m := range n.Members {
588+
label := m.SandboxID
589+
if m.Name != "" {
590+
label = m.Name
591+
}
592+
details := fmt.Sprintf("id: %s", m.SandboxID)
593+
if m.Status != "" {
594+
details += ", status: " + m.Status
595+
}
596+
if m.IP != "" {
597+
details += ", ip: " + m.IP
598+
}
599+
opt := fmt.Sprintf("sandbox: %s (%s)", label, details)
600+
options = append(options, opt)
601+
refByOpt[opt] = m.SandboxID
602+
}
603+
for _, d := range devs {
604+
if !deviceAttachedToNetwork(n, deviceNetworks[d.ID]) {
605+
continue
606+
}
607+
opt := fmt.Sprintf("device: %s (%s, id: %s)", d.Name, d.ClientIP, d.ID)
608+
options = append(options, opt)
609+
refByOpt[opt] = d.ID
610+
}
611+
return options, refByOpt
612+
}
613+
614+
func deviceAttachedToNetwork(n *api.SandboxNetwork, attached []api.DeviceNetworkAttachmentView) bool {
615+
for _, a := range attached {
616+
if a.NetworkID == n.ID || a.NetworkName == n.Name {
617+
return true
618+
}
619+
}
620+
return false
621+
}
622+
541623
// pickNetwork renders a single-select picker over the caller's networks
542624
// and returns the picked NAME (the server accepts it wherever an ID
543625
// works). Returns "" when the user cancels.
@@ -551,10 +633,11 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin
551633
pterm.Println(pterm.Gray(" Create one with: createos sandbox network create <name>"))
552634
return "", nil
553635
}
636+
deviceCounts := countDevicesByNetwork(c, client)
554637
options := make([]string, 0, len(nets))
555638
byOpt := make(map[string]string, len(nets))
556639
for _, n := range nets {
557-
opt := fmt.Sprintf("%s (sandboxes: %d, id: %s)", n.Name, n.MemberCount, n.ID)
640+
opt := networkPickerOption(n, deviceCounts)
558641
options = append(options, opt)
559642
byOpt[opt] = n.Name
560643
}
@@ -567,3 +650,35 @@ func pickNetwork(c *cli.Context, client *api.SandboxClient, title string) (strin
567650
}
568651
return byOpt[picked], nil
569652
}
653+
654+
func countDevicesByNetwork(c *cli.Context, client *api.SandboxClient) map[string]int {
655+
counts := make(map[string]int)
656+
devs, err := client.ListDevices(c.Context)
657+
if err != nil {
658+
return counts
659+
}
660+
for _, d := range devs {
661+
nets, nerr := client.ListDeviceNetworks(c.Context, d.ID)
662+
if nerr != nil {
663+
continue
664+
}
665+
for _, n := range nets {
666+
if n.NetworkID != "" {
667+
counts[n.NetworkID]++
668+
continue
669+
}
670+
if n.NetworkName != "" {
671+
counts[n.NetworkName]++
672+
}
673+
}
674+
}
675+
return counts
676+
}
677+
678+
func networkPickerOption(n api.SandboxNetwork, deviceCounts map[string]int) string {
679+
deviceCount := deviceCounts[n.ID]
680+
if deviceCount == 0 {
681+
deviceCount = deviceCounts[n.Name]
682+
}
683+
return fmt.Sprintf("%s (sandboxes: %d, devices: %d, id: %s)", n.Name, n.MemberCount, deviceCount, n.ID)
684+
}

‎cmd/sandbox/network_test.go‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package sandbox
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/NodeOps-app/createos-cli/internal/api"
8+
)
9+
10+
func TestLooksLikeSandboxRef(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
ref string
15+
want bool
16+
}{
17+
{ref: "sb-01m10y7j0qgphydk8awvmnbza3", want: true},
18+
{ref: "sb_01m10y7j0qgphydk8awvmnbza3", want: true},
19+
{ref: "bhautikin", want: false},
20+
{ref: "dev-01m10y7j0qgphydk8awvmnbza3", want: false},
21+
}
22+
for _, tt := range tests {
23+
if got := looksLikeSandboxRef(tt.ref); got != tt.want {
24+
t.Fatalf("looksLikeSandboxRef(%q) = %v, want %v", tt.ref, got, tt.want)
25+
}
26+
}
27+
}
28+
29+
func TestNetworkMemberEndpointOptionsOnlyIncludesAttachedMembers(t *testing.T) {
30+
t.Parallel()
31+
32+
network := &api.SandboxNetwork{
33+
ID: "net-123",
34+
Name: "bhautikin",
35+
Members: []api.SandboxNetworkMember{{
36+
SandboxID: "sb-1",
37+
Name: "app",
38+
Status: "running",
39+
IP: "10.0.0.4",
40+
}},
41+
}
42+
devs := []api.DeviceView{
43+
{ID: "dev-1", Name: "laptop", ClientIP: "100.64.0.8"},
44+
{ID: "dev-2", Name: "desktop", ClientIP: "100.64.0.9"},
45+
}
46+
deviceNetworks := map[string][]api.DeviceNetworkAttachmentView{
47+
"dev-1": {{NetworkID: "net-123", NetworkName: "bhautikin"}},
48+
"dev-2": {{NetworkID: "net-other", NetworkName: "other"}},
49+
}
50+
51+
options, refs := networkMemberEndpointOptions(network, devs, deviceNetworks)
52+
wantOptions := []string{
53+
"sandbox: app (id: sb-1, status: running, ip: 10.0.0.4)",
54+
"device: laptop (100.64.0.8, id: dev-1)",
55+
}
56+
if !reflect.DeepEqual(options, wantOptions) {
57+
t.Fatalf("options = %#v, want %#v", options, wantOptions)
58+
}
59+
if refs[options[0]] != "sb-1" {
60+
t.Fatalf("sandbox ref = %q", refs[options[0]])
61+
}
62+
if refs[options[1]] != "dev-1" {
63+
t.Fatalf("device ref = %q", refs[options[1]])
64+
}
65+
}
66+
67+
func TestDeviceAttachedToNetworkMatchesNameOrID(t *testing.T) {
68+
t.Parallel()
69+
70+
network := &api.SandboxNetwork{ID: "net-123", Name: "bhautikin"}
71+
if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-123"}}) {
72+
t.Fatal("expected ID match")
73+
}
74+
if !deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkName: "bhautikin"}}) {
75+
t.Fatal("expected name match")
76+
}
77+
if deviceAttachedToNetwork(network, []api.DeviceNetworkAttachmentView{{NetworkID: "net-other", NetworkName: "other"}}) {
78+
t.Fatal("unexpected match")
79+
}
80+
}

0 commit comments

Comments
 (0)