From f19daba52f11161917f57cec46c21a7085f807ed Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 19 Aug 2026 17:07:11 +0530 Subject: [PATCH 1/2] Add managed sandbox process sessions --- .tool-versions | 2 +- README.md | 36 + cmd/auth/whoami.go | 2 +- cmd/cronjobs/activities.go | 2 +- cmd/cronjobs/get.go | 6 +- cmd/cronjobs/list.go | 2 +- cmd/deployments/helpers.go | 6 +- cmd/deployments/list.go | 2 +- cmd/environments/list.go | 2 +- cmd/oauth/list.go | 2 +- cmd/open/open.go | 2 +- cmd/projects/get.go | 2 +- cmd/projects/list.go | 2 +- cmd/sandbox/disk.go | 4 +- cmd/sandbox/exec.go | 12 +- cmd/sandbox/get.go | 2 +- cmd/sandbox/list.go | 2 +- cmd/sandbox/network.go | 4 +- cmd/sandbox/process.go | 1545 +++++++++++++++++++++++++ cmd/sandbox/sandbox.go | 1 + cmd/sandbox/shell.go | 12 +- cmd/sandbox/template.go | 10 +- cmd/templates/info.go | 2 +- cmd/vms/get.go | 4 +- cmd/vms/list.go | 2 +- cmd/webhooks/get.go | 4 +- internal/api/sandbox_process.go | 220 ++++ internal/api/sandbox_process_types.go | 89 ++ internal/api/types.go | 9 + internal/ui/catalog_list.go | 2 +- internal/ui/skills_list.go | 2 +- 31 files changed, 1955 insertions(+), 39 deletions(-) create mode 100644 cmd/sandbox/process.go create mode 100644 internal/api/sandbox_process.go create mode 100644 internal/api/sandbox_process_types.go diff --git a/.tool-versions b/.tool-versions index f201492..f3d9292 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -golang 1.26.1 +golang 1.26.3 golangci-lint 2.11.3 diff --git a/README.md b/README.md index 25445e5..e7bd300 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,7 @@ Sandboxes are fast-booting VMs — isolated environments you can exec into, sync | `createos sandbox rm` | Delete one or more sandboxes | | `createos sandbox exec` | Run a command inside a sandbox | | `createos sandbox shell` | Open an interactive shell inside a sandbox | +| `createos sandbox process` | Manage reconnectable processes and shell sessions | | `createos sandbox sync` | Two-way file sync between your laptop and a sandbox | | `createos sandbox push` | Copy a local file into a sandbox | | `createos sandbox pull` | Copy a file out of a sandbox | @@ -327,6 +328,28 @@ Sandboxes are fast-booting VMs — isolated environments you can exec into, sync | `--ingress` | Give the sandbox a public HTTPS URL | | `--auto-pause` | Auto-pause after inactivity (e.g. `10m`, `1h`). Omit to keep running. | +**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. + +| Command | Description | +| -------------------------------------------- | ------------------------------------------------------------ | +| `createos sandbox process run -- ` | Run a managed command, stream output, and return its exit code | +| `createos sandbox process start -- ` | Start a managed command and print its process ID | +| `createos sandbox process shell ` | Start a persistent shell session that can be reattached | +| `createos sandbox process attach ` | Reconnect to process output or a shell session | +| `createos sandbox process attach ` | Pick a running shell session and attach/switch to it | +| `createos sandbox process list ` | List managed processes and shell sessions | +| `createos sandbox process get ` | Show details for one managed process | +| `createos sandbox process input ` | Write input to a process or shell session | +| `createos sandbox process close-stdin ` | Close stdin for a pipe process | +| `createos sandbox process resize ` | Resize a managed shell session | +| `createos sandbox process signal ` | Send a signal such as `SIGINT` or `SIGTERM` | +| `createos sandbox process wait ` | Wait for a managed process to exit | +| `createos sandbox process stop ` | Stop a process and anything it started | + +Managed shell attach shortcuts are fixed: `Ctrl-]` detaches, `Ctrl-N` creates a new shell and switches to it, and `Ctrl-P` picks another running shell session. + **Sandbox sub-resource commands:** | Command | Description | @@ -493,6 +516,19 @@ createos sandbox exec my-box -- uname -a createos sandbox exec my-box --stream -- pip install requests createos sandbox shell my-box createos sandbox shell my-box --ssh +createos sandbox process run my-box -- npm test +createos sandbox process start my-box -- python -m http.server 8000 +createos sandbox process shell my-box +createos sandbox process attach my-box proc_abc123 +createos sandbox process attach my-box # pick a running shell session +createos sandbox process ps my-box +createos sandbox process input my-box proc_abc123 --text "hello\n" +createos sandbox process signal my-box proc_abc123 SIGINT +createos sandbox process wait my-box proc_abc123 --all +createos sandbox process stop my-box proc_abc123 --grace 1s +# Inside a managed shell session, the fixed bottom bar shows active shortcuts. +# detach closes the local attach; new creates a shell and switches; +# switch picks another shell; `exit` closes the current shell. createos sandbox push my-box ./script.py /root/script.py createos sandbox pull my-box /root/output.csv ./output.csv createos sandbox tunnel my-box --local 8080 --remote 8000 diff --git a/cmd/auth/whoami.go b/cmd/auth/whoami.go index 6a13746..a68930b 100644 --- a/cmd/auth/whoami.go +++ b/cmd/auth/whoami.go @@ -34,7 +34,7 @@ func NewWhoamiCommand() *cli.Command { createdAt, err := time.Parse(time.RFC3339Nano, u.CreatedAt) memberSince := u.CreatedAt if err == nil { - memberSince = createdAt.Format("January 2, 2006") + memberSince = createdAt.Local().Format("January 2, 2006") } fmt.Println() diff --git a/cmd/cronjobs/activities.go b/cmd/cronjobs/activities.go index e636c2f..4fac97b 100644 --- a/cmd/cronjobs/activities.go +++ b/cmd/cronjobs/activities.go @@ -67,7 +67,7 @@ func newCronjobsActivitiesCommand() *cli.Command { a.ID, success, statusCode, - a.ScheduledAt.Format("2006-01-02 15:04:05"), + a.ScheduledAt.Local().Format("2006-01-02 15:04:05"), log, }) } diff --git a/cmd/cronjobs/get.go b/cmd/cronjobs/get.go index 90f9e3b..afcd860 100644 --- a/cmd/cronjobs/get.go +++ b/cmd/cronjobs/get.go @@ -61,7 +61,7 @@ func newCronjobsGetCommand() *cli.Command { if cj.SuspendedAt != nil { label.Print("Suspended At: ") - fmt.Println(cj.SuspendedAt.Format("2006-01-02 15:04:05")) + fmt.Println(cj.SuspendedAt.Local().Format("2006-01-02 15:04:05")) } if cj.SuspendText != nil && *cj.SuspendText != "" { label.Print("Suspend Text: ") @@ -93,9 +93,9 @@ func newCronjobsGetCommand() *cli.Command { } label.Print("Created At: ") - fmt.Println(cj.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(cj.CreatedAt.Local().Format("2006-01-02 15:04:05")) label.Print("Updated At: ") - fmt.Println(cj.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(cj.UpdatedAt.Local().Format("2006-01-02 15:04:05")) return nil }, diff --git a/cmd/cronjobs/list.go b/cmd/cronjobs/list.go index ff0b8e9..3d49d7b 100644 --- a/cmd/cronjobs/list.go +++ b/cmd/cronjobs/list.go @@ -50,7 +50,7 @@ func newCronjobsListCommand() *cli.Command { cj.Schedule, cj.Type, cj.Status, - cj.CreatedAt.Format("2006-01-02 15:04:05"), + cj.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck diff --git a/cmd/deployments/helpers.go b/cmd/deployments/helpers.go index bbc095d..c221554 100644 --- a/cmd/deployments/helpers.go +++ b/cmd/deployments/helpers.go @@ -78,7 +78,7 @@ func pickDeployment(client *api.APIClient, projectID string, statusFilter []stri d.ID, d.Status, d.Extra.Endpoint, - d.CreatedAt.Format("2006-01-02 15:04:05"), + d.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } fmt.Println() @@ -93,7 +93,7 @@ func pickDeployment(client *api.APIClient, projectID string, statusFilter []stri if len(id) > 8 { id = id[:8] } - label := fmt.Sprintf("%s %s %s", d.CreatedAt.Format("Jan 02 15:04"), d.Status, id) + label := fmt.Sprintf("%s %s %s", d.CreatedAt.Local().Format("Jan 02 15:04"), d.Status, id) if d.Source != nil && d.Source.Commit != "" { commit := d.Source.Commit if len(commit) > 7 { @@ -103,7 +103,7 @@ func pickDeployment(client *api.APIClient, projectID string, statusFilter []stri if len(msg) > 50 { msg = msg[:50] + "…" } - label = fmt.Sprintf("%s %s %s %s %s", d.CreatedAt.Format("Jan 02 15:04"), d.Status, id, commit, msg) + label = fmt.Sprintf("%s %s %s %s %s", d.CreatedAt.Local().Format("Jan 02 15:04"), d.Status, id, commit, msg) } options[i] = label } diff --git a/cmd/deployments/list.go b/cmd/deployments/list.go index c497c78..e85387e 100644 --- a/cmd/deployments/list.go +++ b/cmd/deployments/list.go @@ -47,7 +47,7 @@ func newDeploymentsListCommand() *cli.Command { d.ID, d.Status, d.Extra.Endpoint, - d.CreatedAt.Format("2006-01-02 15:04:05"), + d.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { diff --git a/cmd/environments/list.go b/cmd/environments/list.go index c338396..063cc7b 100644 --- a/cmd/environments/list.go +++ b/cmd/environments/list.go @@ -59,7 +59,7 @@ func newEnvironmentsListCommand() *cli.Command { env.Status, env.Extra.Endpoint, domains, - env.CreatedAt.Format("2006-01-02 15:04:05"), + env.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { diff --git a/cmd/oauth/list.go b/cmd/oauth/list.go index 6cecc05..82cf09b 100644 --- a/cmd/oauth/list.go +++ b/cmd/oauth/list.go @@ -37,7 +37,7 @@ func newListCommand() *cli.Command { tableData = append(tableData, []string{ item.ID, item.Name, - item.CreatedAt.Format("2006-01-02 15:04:05"), + item.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } if err := pterm.DefaultTable.WithHasHeader().WithData(tableData).Render(); err != nil { diff --git a/cmd/open/open.go b/cmd/open/open.go index 218e647..a3d496f 100644 --- a/cmd/open/open.go +++ b/cmd/open/open.go @@ -94,7 +94,7 @@ func NewOpenCommand() *cli.Command { if len(id) > 8 { id = id[:8] } - options[i] = fmt.Sprintf("%s %s %s", d.CreatedAt.Format("Jan 02 15:04"), d.Status, id) + options[i] = fmt.Sprintf("%s %s %s", d.CreatedAt.Local().Format("Jan 02 15:04"), d.Status, id) } if !terminal.IsInteractive() { return fmt.Errorf("multiple deployments found — use 'createos deployments list' and pass the deployment ID") diff --git a/cmd/projects/get.go b/cmd/projects/get.go index f493cd4..6be9419 100644 --- a/cmd/projects/get.go +++ b/cmd/projects/get.go @@ -81,7 +81,7 @@ func newGetCommand() *cli.Command { fmt.Println(project.Status) cyan.Printf("Created At: ") - fmt.Println(project.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(project.CreatedAt.Local().Format("2006-01-02 15:04:05")) return nil }, diff --git a/cmd/projects/list.go b/cmd/projects/list.go index 5fd4ea3..e3b8e05 100644 --- a/cmd/projects/list.go +++ b/cmd/projects/list.go @@ -40,7 +40,7 @@ func newListCommand() *cli.Command { p.DisplayName, p.Status, p.Type, - p.CreatedAt.Format("2006-01-02 15:04:05"), + p.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck diff --git a/cmd/sandbox/disk.go b/cmd/sandbox/disk.go index 3650cbe..d06b3d6 100644 --- a/cmd/sandbox/disk.go +++ b/cmd/sandbox/disk.go @@ -191,7 +191,7 @@ func runDiskList(c *cli.Context) error { for _, d := range disks { table = append(table, []string{ d.Name, d.ID, d.Kind, d.Config.Bucket, - d.CreatedAt.Format("2006-01-02 15:04"), + d.CreatedAt.Local().Format("2006-01-02 15:04"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck @@ -253,7 +253,7 @@ func runDiskShow(c *cli.Context) error { if d.Config.UsePathStyle { row("Path style", "yes") } - row("Created", d.CreatedAt.Format("2006-01-02 15:04:05")) + row("Created", d.CreatedAt.Local().Format("2006-01-02 15:04:05")) }) return nil } diff --git a/cmd/sandbox/exec.go b/cmd/sandbox/exec.go index 6adfbb9..8c33420 100644 --- a/cmd/sandbox/exec.go +++ b/cmd/sandbox/exec.go @@ -18,11 +18,19 @@ func newExecCommand() *cli.Command { Name: "exec", Usage: "Run a command inside a sandbox", ArgsUsage: " -- [args…]", - Description: `Run a one-shot command inside a sandbox. Anything after the literal -'--' becomes the command. The default is a buffered exec — output + Description: `Run a one-shot command inside a sandbox. + +Use this for quick non-interactive commands where you do not need a +process ID, reconnect, stdin after start, or a TTY. Anything after the +literal '--' becomes the command. The default is buffered exec — output arrives all at once when the command finishes. Pass --stream to see stdout/stderr live as it happens. +Use 'sandbox shell' for an immediate interactive terminal. +Use 'sandbox process' for long-running or reconnectable commands. +Use 'sandbox process run --pty' when the command needs a TTY but should +also be managed as a process. + Examples: createos sandbox exec my-box -- uname -a createos sandbox exec my-box -- python3 -c 'print("hi")' diff --git a/cmd/sandbox/get.go b/cmd/sandbox/get.go index b94be0f..9547ac5 100644 --- a/cmd/sandbox/get.go +++ b/cmd/sandbox/get.go @@ -89,7 +89,7 @@ func printSandbox(s *api.SandboxView, bw *api.BandwidthView) { if t == nil { return } - row(k, t.Format("2006-01-02 15:04:05")) + row(k, t.Local().Format("2006-01-02 15:04:05")) } // Header diff --git a/cmd/sandbox/list.go b/cmd/sandbox/list.go index 95a784f..9476b8a 100644 --- a/cmd/sandbox/list.go +++ b/cmd/sandbox/list.go @@ -139,7 +139,7 @@ func runList(c *cli.Context) error { r.Status, r.Shape, ptrOrDash(r.IP), - r.CreatedAt.Format("2006-01-02 15:04"), + r.CreatedAt.Local().Format("2006-01-02 15:04"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck diff --git a/cmd/sandbox/network.go b/cmd/sandbox/network.go index 5b82cd5..48e44d7 100644 --- a/cmd/sandbox/network.go +++ b/cmd/sandbox/network.go @@ -107,7 +107,7 @@ func runNetworkList(c *cli.Context) error { table = append(table, []string{ n.Name, n.ID, fmt.Sprintf("%d", n.MemberCount), - n.CreatedAt.Format("2006-01-02 15:04"), + n.CreatedAt.Local().Format("2006-01-02 15:04"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck @@ -162,7 +162,7 @@ func runNetworkShow(c *cli.Context) error { pterm.Println() pterm.NewStyle(pterm.FgCyan, pterm.Bold).Printfln(" %s (%s)", n.Name, n.ID) pterm.Println() - row("Created", n.CreatedAt.Format("2006-01-02 15:04:05")) + row("Created", n.CreatedAt.Local().Format("2006-01-02 15:04:05")) row("Sandboxes", fmt.Sprintf("%d", n.MemberCount)) if len(n.Members) > 0 { diff --git a/cmd/sandbox/process.go b/cmd/sandbox/process.go new file mode 100644 index 0000000..e0d21f4 --- /dev/null +++ b/cmd/sandbox/process.go @@ -0,0 +1,1545 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/pterm/pterm" + "github.com/urfave/cli/v2" + "golang.org/x/term" + + "github.com/NodeOps-app/createos-cli/internal/api" + "github.com/NodeOps-app/createos-cli/internal/output" + "github.com/NodeOps-app/createos-cli/internal/terminal" + "github.com/NodeOps-app/createos-cli/internal/ui" +) + +func newProcessCommand() *cli.Command { + return &cli.Command{ + Name: "process", + Aliases: []string{"proc"}, + Usage: "Manage long-running and reconnectable sandbox processes", + Description: `Manage commands that keep a process ID. + +Use this when a command should keep running independently of this CLI, +or when you want to list it, reconnect to output, send input, wait for it, +or stop it later. + +Use 'sandbox exec' for quick non-interactive one-shot commands. +Use 'sandbox shell' for an immediate interactive terminal that does not +need to be listed or reattached. +Use --pty (alias --tty, -t) when the managed command needs a terminal +instead of plain stdout/stderr pipes.`, + Subcommands: []*cli.Command{ + newProcessRunCommand(), + newProcessStartCommand(), + newProcessShellCommand(), + newProcessAttachCommand(), + newProcessListCommand(), + newProcessGetCommand(), + newProcessInputCommand(), + newProcessCloseStdinCommand(), + newProcessResizeCommand(), + newProcessSignalCommand(), + newProcessWaitCommand(), + newProcessStopCommand(), + }, + } +} + +func newProcessRunCommand() *cli.Command { + return &cli.Command{ + Name: "run", + Usage: "Run a managed command, stream output, and return its exit code", + ArgsUsage: " -- [args...]", + Description: `Run a command as a managed process. + +Compared with 'sandbox exec', this keeps replayable output and a process ID +while the command runs. Use this when you may need durable output, input, +signals, wait/stop controls, or later inspection. + +By default this uses stdout/stderr pipes. Add --pty/--tty/-t for commands +that need terminal behavior, such as REPLs, curses apps, colored TTY output, +or programs that behave differently when not attached to a terminal.`, + Flags: processCreateFlags(true), + Action: func(c *cli.Context) error { + return runProcessCreate(c, true, false) + }, + } +} + +func newProcessStartCommand() *cli.Command { + return &cli.Command{ + Name: "start", + Usage: "Start a managed command and print its process ID", + ArgsUsage: " -- [args...]", + Description: `Start a managed command without attaching by default. + +Use this for background work you want to inspect or control later with +'process list', 'process attach', 'process wait', 'process signal', or +'process stop'. Add --follow to attach immediately after starting. + +By default this uses stdout/stderr pipes. Add --pty/--tty/-t when the +program needs a terminal.`, + Flags: processStartFlags(), + Action: func(c *cli.Context) error { + return runProcessCreate(c, false, false) + }, + } +} + +func newProcessShellCommand() *cli.Command { + return &cli.Command{ + Name: "shell", + Usage: "Start a persistent shell session and attach to it", + ArgsUsage: "", + Description: `Start a managed PTY shell session. + +This does not replace 'sandbox shell'. The existing command is unchanged. +Use this when the shell should survive detach and be reattached later. +It creates a process ID, appears in 'process list', and supports switching +between running shell sessions from attach. + +Use 'sandbox shell' when you only need a direct interactive shell right now.`, + Flags: []cli.Flag{ + &cli.StringFlag{Name: "cwd", Usage: "Working directory inside the sandbox"}, + &cli.StringSliceFlag{Name: "env", Usage: "Environment variable override (repeatable): KEY=VALUE"}, + &cli.IntFlag{Name: "rows", Usage: "Initial PTY rows"}, + &cli.IntFlag{Name: "cols", Usage: "Initial PTY columns"}, + &cli.StringFlag{Name: "cmd", Usage: "Explicit shell executable (backend default if omitted)"}, + &cli.BoolFlag{Name: "no-attach", Usage: "Create the shell session and print the process ID without attaching"}, + }, + Action: runProcessShell, + } +} + +func newProcessAttachCommand() *cli.Command { + return &cli.Command{ + Name: "attach", + Aliases: []string{"connect"}, + Usage: "Reconnect to a process, or pick a running shell session", + ArgsUsage: " []", + Description: `Reconnect to a managed process or shell session. + +Use this for process IDs created by 'process run', 'process start', or +'process shell'. For PTY shell sessions, attach is interactive. For pipe +processes, attach follows retained stdout/stderr output. + +If you omit the process ID in an interactive terminal, attach shows a picker +of running shell sessions. Inside a shell session, the default shortcuts are +Ctrl-] to detach, Ctrl-N to create a new shell, and Ctrl-P to pick another +running shell session.`, + Flags: []cli.Flag{ + &cli.Int64Flag{Name: "after", Usage: "Replay output after this sequence number"}, + &cli.BoolFlag{Name: "no-follow", Usage: "Replay retained output and exit"}, + &cli.BoolFlag{Name: "stdin", Usage: "Send local stdin to the process (default for PTYs)"}, + &cli.BoolFlag{Name: "raw", Usage: "Print raw output bytes"}, + }, + Action: runProcessAttach, + } +} + +func newProcessListCommand() *cli.Command { + return &cli.Command{ + Name: "list", + Aliases: []string{"ls", "ps"}, + Usage: "List managed processes and shell sessions", + ArgsUsage: "", + Action: runProcessList, + } +} + +func newProcessGetCommand() *cli.Command { + return &cli.Command{ + Name: "get", + Usage: "Show details for one managed process", + ArgsUsage: " ", + Action: runProcessGet, + } +} + +func newProcessInputCommand() *cli.Command { + return &cli.Command{ + Name: "input", + Usage: "Write input to a managed process or shell session", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "text", Usage: "Text to send"}, + &cli.StringFlag{Name: "file", Usage: "File to send, or '-' for stdin"}, + &cli.StringFlag{Name: "base64", Usage: "Already-base64-encoded bytes to send"}, + }, + Action: runProcessInput, + } +} + +func newProcessCloseStdinCommand() *cli.Command { + return &cli.Command{ + Name: "close-stdin", + Usage: "Close stdin for a pipe process", + ArgsUsage: " ", + Action: runProcessCloseStdin, + } +} + +func newProcessResizeCommand() *cli.Command { + return &cli.Command{ + Name: "resize", + Usage: "Resize a managed shell session", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.IntFlag{Name: "rows", Usage: "PTY rows"}, + &cli.IntFlag{Name: "cols", Usage: "PTY columns"}, + }, + Action: runProcessResize, + } +} + +func newProcessSignalCommand() *cli.Command { + return &cli.Command{ + Name: "signal", + Usage: "Send a signal to a managed process", + ArgsUsage: " ", + Action: runProcessSignal, + } +} + +func newProcessWaitCommand() *cli.Command { + return &cli.Command{ + Name: "wait", + Usage: "Wait for a managed process to exit", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.BoolFlag{Name: "all", Usage: "Wait for the command and anything it started"}, + &cli.DurationFlag{Name: "timeout", Usage: "Stop waiting after this long"}, + }, + Action: runProcessWait, + } +} + +func newProcessStopCommand() *cli.Command { + return &cli.Command{ + Name: "stop", + Aliases: []string{"kill", "rm"}, + Usage: "Stop a managed process and everything it started", + ArgsUsage: " ", + Flags: []cli.Flag{ + &cli.DurationFlag{Name: "grace", Value: time.Second, Usage: "Time to wait after SIGTERM before force-killing"}, + &cli.BoolFlag{Name: "force", Aliases: []string{"f", "yes", "y"}, Usage: "Skip confirmation prompt"}, + }, + Action: runProcessStop, + } +} + +func processCreateFlags(includeRunFlags bool) []cli.Flag { + flags := []cli.Flag{ + &cli.StringFlag{Name: "cwd", Usage: "Working directory inside the sandbox"}, + &cli.StringSliceFlag{Name: "env", Usage: "Environment variable override (repeatable): KEY=VALUE"}, + &cli.BoolFlag{Name: "pty", Aliases: []string{"tty", "t"}, Usage: "Create a managed PTY instead of separate stdout/stderr pipes"}, + &cli.IntFlag{Name: "rows", Usage: "Initial PTY rows"}, + &cli.IntFlag{Name: "cols", Usage: "Initial PTY columns"}, + &cli.Int64Flag{Name: "after", Usage: "When following output, replay after this sequence number"}, + } + if includeRunFlags { + flags = append(flags, + &cli.BoolFlag{Name: "no-follow", Usage: "Do not attach to output after creating"}, + &cli.BoolFlag{Name: "all", Usage: "Wait for the command and anything it started"}, + &cli.DurationFlag{Name: "timeout", Usage: "Stop waiting after this long"}, + ) + } + return flags +} + +func processStartFlags() []cli.Flag { + flags := processCreateFlags(false) + flags = append(flags, &cli.BoolFlag{Name: "follow", Usage: "Attach to output after starting"}) + return flags +} + +func runProcessCreate(c *cli.Context, waitForExit bool, shellMode bool) error { + client, id, ref, err := processClientAndSandbox(c, true) + if err != nil { + return err + } + req, err := processCreateRequestFromCLI(c, shellMode) + if err != nil { + return err + } + proc, err := client.CreateProcess(c.Context, id, req) + if err != nil { + return err + } + follow := waitForExit + if !waitForExit { + follow = c.Bool("follow") + } + if c.Bool("no-follow") { + follow = false + } + if output.IsJSON(c) { + if waitForExit && follow { + final, waitErr := waitForManagedProcess(c.Context, client, id, proc.ProcessID, c.Bool("all"), c.Duration("timeout")) + if waitErr != nil { + return waitErr + } + output.Render(c, final, func() {}) + return exitFromProcess(final.ExitCode, final.Signal) + } + output.Render(c, proc, func() {}) + return nil + } + if !follow { + printProcessCreated(proc) + return nil + } + if !waitForExit { + printProcessCreated(proc) + } + exitCode, signal, err := attachProcess(c, client, id, ref, proc, c.Int64("after"), false, false) + if err != nil { + return err + } + if waitForExit { + return exitFromProcess(exitCode, signal) + } + return nil +} + +func runProcessShell(c *cli.Context) error { + client, id, ref, err := processClientAndSandbox(c, false) + if err != nil { + return err + } + envs, err := parseEnvFlags(c.StringSlice("env")) + if err != nil { + return err + } + req := api.ProcessCreateRequest{ + Cmd: c.String("cmd"), + Cwd: strings.TrimSpace(c.String("cwd")), + Env: envs, + PTY: ptyOptionsFromFlags(c, !output.IsJSON(c) && !c.Bool("no-attach")), + } + proc, err := client.CreateProcess(c.Context, id, req) + if err != nil { + return err + } + if output.IsJSON(c) || c.Bool("no-attach") { + output.Render(c, proc, func() { printProcessCreated(proc) }) + return nil + } + printProcessCreated(proc) + exitCode, signal, err := attachProcess(c, client, id, ref, proc, 0, false, false) + if err != nil { + return err + } + return exitFromProcess(exitCode, signal) +} + +func runProcessAttach(c *cli.Context) error { + var ( + client *api.SandboxClient + id string + ref string + processID string + err error + ) + if c.Args().Len() == 0 { + client, id, ref, err = processClientAndSandbox(c, false) + if err != nil { + return err + } + } else { + client, id, ref, processID, err = processClientSandboxAndOptionalProcess(c) + if err != nil { + return err + } + } + if processID == "" { + processID, err = pickRunningPTY(c, client, id, "Attach to which shell session?") + if err != nil { + if errors.Is(err, errNoRunningPTY) && !c.Bool("no-follow") { + proc, createErr := promptCreatePTYAndAttach(c, client, id) + if createErr != nil { + return createErr + } + if proc == nil { + fmt.Println("Cancelled. Nothing attached.") + return nil + } + printProcessCreated(proc) + _, _, attachErr := attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, proc, false), false, c.Bool("stdin")) + return attachErr + } + return err + } + if processID == "" { + fmt.Println("Cancelled. Nothing attached.") + return nil + } + } + proc, err := client.GetProcess(c.Context, id, processID) + if err != nil { + return err + } + noFollow := c.Bool("no-follow") + _, _, err = attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, proc, noFollow), noFollow, c.Bool("stdin")) + return err +} + +func runProcessList(c *cli.Context) error { + client, id, _, err := processClientAndSandbox(c, false) + if err != nil { + return err + } + processes, err := client.ListProcesses(c.Context, id) + if err != nil { + return err + } + output.Render(c, processes, func() { + if len(processes) == 0 { + fmt.Println("No managed processes are running or retained in this sandbox.") + return + } + table := pterm.TableData{{"ID", "Kind", "PID", "State", "Exit", "Command", "Output", "Created"}} + for _, p := range processes { + table = append(table, []string{ + p.ProcessID, + p.Kind, + strconv.Itoa(p.PID), + processStateLabel(p), + processExitLabel(p), + processCommandLabel(p, 40), + fmt.Sprintf("%d bytes", p.Output.Bytes), + processLocalDateTime(p.CreatedAt), + }) + } + _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck + }) + return nil +} + +func runProcessGet(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + proc, err := client.GetProcess(c.Context, id, processID) + if err != nil { + return err + } + output.Render(c, proc, func() { printProcessDetails(proc) }) + return nil +} + +func runProcessInput(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + encoded, err := processInputBase64(c) + if err != nil { + return err + } + resp, err := client.WriteProcessInput(c.Context, id, processID, api.ProcessInputRequest{DataBase64: encoded}) + if err != nil { + return err + } + output.Render(c, resp, func() { + pterm.Success.Printf("Input accepted as write #%d.\n", resp.InputSeq) + }) + return nil +} + +func runProcessCloseStdin(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + if err := client.CloseProcessStdin(c.Context, id, processID); err != nil { + return err + } + pterm.Success.Println("Stdin closed.") + return nil +} + +func runProcessResize(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + rows, cols, err := processRowsCols(c) + if err != nil { + return err + } + if err := client.ResizeProcessPTY(c.Context, id, processID, api.ProcessResizeRequest{Rows: rows, Cols: cols}); err != nil { + return err + } + pterm.Success.Printf("Resized PTY to %dx%d.\n", rows, cols) + return nil +} + +func runProcessSignal(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + if c.Args().Len() < 3 { + return fmt.Errorf("please provide a signal\n\n Example:\n createos sandbox process signal %s %s SIGINT", c.Args().Get(0), processID) + } + sig := strings.ToUpper(strings.TrimSpace(c.Args().Get(2))) + if sig == "" { + return fmt.Errorf("please provide a signal, for example SIGINT or SIGTERM") + } + if err := client.SignalProcess(c.Context, id, processID, api.ProcessSignalRequest{Signal: sig}); err != nil { + return err + } + pterm.Success.Printf("Sent %s to %s.\n", sig, processID) + return nil +} + +func runProcessWait(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + proc, err := waitForManagedProcess(c.Context, client, id, processID, processBoolFlag(c, "wait", "all"), processDurationFlag(c, "wait", "timeout")) + if err != nil { + return err + } + output.Render(c, proc, func() { printProcessDetails(proc) }) + return nil +} + +func runProcessStop(c *cli.Context) error { + client, id, processID, err := processClientSandboxAndProcess(c) + if err != nil { + return err + } + force := processBoolFlag(c, "stop", "force") || processBoolFlag(c, "stop", "yes") || processBoolFlag(c, "kill", "force") || processBoolFlag(c, "rm", "force") + if !force && terminal.IsInteractive() { + ok, confirmErr := pterm.DefaultInteractiveConfirm. + WithDefaultText(fmt.Sprintf("Stop process %s and everything it started?", processID)). + WithDefaultValue(false). + Show() + if confirmErr != nil { + return fmt.Errorf("confirmation cancelled") + } + if !ok { + fmt.Println("Cancelled. Nothing stopped.") + return nil + } + } + grace := processDurationFlag(c, "stop", "grace") + if grace == 0 { + grace = processDurationFlag(c, "kill", "grace") + } + if grace == 0 { + grace = processDurationFlag(c, "rm", "grace") + } + proc, err := client.TerminateProcess(c.Context, id, processID, durationMs(grace)) + if err != nil { + return err + } + output.Render(c, proc, func() { + pterm.Success.Printf("Stopped %s.\n", processID) + printProcessDetails(proc) + }) + return nil +} + +func processClientAndSandbox(c *cli.Context, allowCommandAfterRef bool) (*api.SandboxClient, string, string, error) { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return nil, "", "", fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + ref := strings.TrimSpace(c.Args().First()) + if allowCommandAfterRef { + ref, _, _ = parseProcessCommandArgs(c) + } + if ref == "" { + if !terminal.IsInteractive() { + return nil, "", "", fmt.Errorf("please provide a sandbox ID or name") + } + pickedID, label, err := pickByStatus(c, client, "Use which sandbox?", "running") + if err != nil { + return nil, "", "", err + } + if pickedID == "" { + return nil, "", "", fmt.Errorf("cancelled") + } + return client, pickedID, label, nil + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return nil, "", "", err + } + return client, id, ref, nil +} + +func processClientSandboxAndProcess(c *cli.Context) (*api.SandboxClient, string, string, error) { + client, id, ref, processID, err := processClientSandboxAndOptionalProcess(c) + if err != nil { + return nil, "", "", err + } + if processID == "" { + return nil, "", "", fmt.Errorf("please provide a process ID\n\n Example:\n createos sandbox process attach %s proc_abc123", refLabel(ref, id)) + } + return client, id, processID, nil +} + +func processClientSandboxAndOptionalProcess(c *cli.Context) (*api.SandboxClient, string, string, string, error) { + client, ok := c.App.Metadata[api.SandboxClientKey].(*api.SandboxClient) + if !ok { + return nil, "", "", "", fmt.Errorf("you're not signed in — run 'createos login' to get started") + } + if c.Args().Len() < 1 { + return nil, "", "", "", fmt.Errorf("please provide a sandbox ID or name") + } + ref := strings.TrimSpace(c.Args().Get(0)) + if ref == "" { + return nil, "", "", "", fmt.Errorf("please provide a sandbox ID or name") + } + id, err := resolveSandboxRef(c.Context, client, ref) + if err != nil { + return nil, "", "", "", err + } + processID := strings.TrimSpace(c.Args().Get(1)) + return client, id, ref, processID, nil +} + +func processCreateRequestFromCLI(c *cli.Context, shellMode bool) (api.ProcessCreateRequest, error) { + envs, err := parseEnvFlags(c.StringSlice("env")) + if err != nil { + return api.ProcessCreateRequest{}, err + } + ref, cmd, args := parseProcessCommandArgs(c) + _ = ref + if !shellMode && cmd == "" && !c.Bool("pty") { + return api.ProcessCreateRequest{}, fmt.Errorf("please pass the command after '--'\n\n Example:\n createos sandbox process run my-box -- npm test") + } + req := api.ProcessCreateRequest{ + Cmd: cmd, + Args: args, + Cwd: strings.TrimSpace(c.String("cwd")), + Env: envs, + } + if c.Bool("pty") { + req.PTY = ptyOptionsFromFlags(c, false) + } + return req, nil +} + +func parseProcessCommandArgs(c *cli.Context) (ref, cmd string, args []string) { + all := c.Args().Slice() + if len(all) == 0 { + return "", "", nil + } + leadingDoubleDash := false + for i, a := range os.Args { + if (a == "run" || a == "start") && i+1 < len(os.Args) && os.Args[i+1] == "--" { + leadingDoubleDash = true + break + } + } + sep := -1 + for i, a := range all { + if a == "--" { + sep = i + break + } + } + switch { + case leadingDoubleDash: + cmd = all[0] + if len(all) > 1 { + args = all[1:] + } + case sep >= 0: + if sep > 0 { + ref = strings.TrimSpace(all[0]) + } + rest := all[sep+1:] + if len(rest) > 0 { + cmd = rest[0] + args = rest[1:] + } + default: + ref = strings.TrimSpace(all[0]) + if len(all) > 1 { + cmd = all[1] + args = all[2:] + } + } + return ref, cmd, args +} + +func ptyOptionsFromFlags(c *cli.Context, reserveFooter bool) *api.ProcessPTYOptions { + rows, cols := c.Int("rows"), c.Int("cols") + if rows <= 0 || cols <= 0 { + gotRows, gotCols := currentTerminalRowsCols(reserveFooter) + if rows <= 0 { + rows = gotRows + } + if cols <= 0 { + cols = gotCols + } + } + return &api.ProcessPTYOptions{Rows: rows, Cols: cols} +} + +func currentTerminalRowsCols(reserveFooter bool) (int, int) { + rows, cols := 24, 80 + fd := stdinFD() + if term.IsTerminal(fd) { + if gotCols, gotRows, err := term.GetSize(fd); err == nil { + if gotRows > 0 { + rows = gotRows + } + if gotCols > 0 { + cols = gotCols + } + } + } + if reserveFooter && rows > 1 { + rows-- + } + return rows, cols +} + +func processRowsCols(c *cli.Context) (int, int, error) { + rows, cols := c.Int("rows"), c.Int("cols") + if rows <= 0 { + rows = processIntFlag(c, "resize", "rows") + } + if cols <= 0 { + cols = processIntFlag(c, "resize", "cols") + } + if rows <= 0 || cols <= 0 { + fd := stdinFD() + if !term.IsTerminal(fd) { + return 0, 0, fmt.Errorf("please provide --rows and --cols") + } + gotCols, gotRows, err := term.GetSize(fd) + if err != nil { + return 0, 0, fmt.Errorf("could not read terminal size: %w", err) + } + if rows <= 0 { + rows = gotRows + } + if cols <= 0 { + cols = gotCols + } + } + if rows <= 0 || cols <= 0 { + return 0, 0, fmt.Errorf("--rows and --cols must be greater than zero") + } + return rows, cols, nil +} + +func pickRunningPTY(c *cli.Context, client *api.SandboxClient, sandboxID, title string) (string, error) { + if !terminal.IsInteractive() { + return "", fmt.Errorf("please provide a process ID\n\n Example:\n createos sandbox process attach %s ", sandboxID) + } + processes, err := client.ListProcesses(c.Context, sandboxID) + if err != nil { + return "", err + } + items := make([]ui.PickerItem, 0, len(processes)) + for _, proc := range processes { + if proc.Kind != "pty" || proc.LeaderExited || proc.TreeExited || proc.State != "running" { + continue + } + subtitle := fmt.Sprintf("pid: %d, command: %s, created: %s, output: %d bytes", proc.PID, processCommandLabel(proc, 32), processLocalClock(proc.CreatedAt), proc.Output.Bytes) + items = append(items, ui.PickerItem{ + Title: proc.ProcessID, + Subtitle: subtitle, + Value: proc.ProcessID, + }) + } + if len(items) == 0 { + return "", errNoRunningPTY + } + return ui.Pick(title, items) +} + +var errNoRunningPTY = errors.New("there are no running shell sessions in this sandbox") + +func promptCreatePTYAndAttach(c *cli.Context, client *api.SandboxClient, sandboxID string) (*api.ProcessDetails, error) { + if !terminal.IsInteractive() { + return nil, fmt.Errorf("%w\n\n Start one:\n createos sandbox process shell %s", errNoRunningPTY, sandboxID) + } + pterm.Warning.Println("There are no running shell sessions in this sandbox.") + ok, err := pterm.DefaultInteractiveConfirm. + WithDefaultText("Create a new shell session and attach?"). + WithDefaultValue(true). + Show() + if err != nil { + return nil, fmt.Errorf("could not read confirmation: %w", err) + } + if !ok { + return nil, nil + } + return client.CreateProcess(c.Context, sandboxID, api.ProcessCreateRequest{PTY: ptyOptionsFromFlags(c, true)}) +} + +func attachProcess(c *cli.Context, client *api.SandboxClient, sandboxID, ref string, proc *api.ProcessDetails, after int64, noFollow bool, stdinFlag bool) (*int, string, error) { + offsetRetries := 0 + for { + exitCode, signal, nextID, err := attachProcessOnce(c, client, sandboxID, ref, proc, after, noFollow, stdinFlag) + if err != nil { + return exitCode, signal, err + } + if retryAfter, ok := processAttachRetryAfter(nextID); ok { + offsetRetries++ + if offsetRetries > 1 { + return nil, "", fmt.Errorf("some previous output is no longer available; try attaching again without --after, or run 'createos sandbox process get %s %s' to see the retained output range", refLabel(ref, sandboxID), proc.ProcessID) + } + nextProc, getErr := client.GetProcess(c.Context, sandboxID, proc.ProcessID) + if getErr != nil { + return nil, "", getErr + } + proc = nextProc + if retryAfter == 0 && proc.Output.OldestSeq > 0 { + retryAfter = proc.Output.OldestSeq + } + after = retryAfter + stdinFlag = false + continue + } + if nextID == "" || noFollow { + return exitCode, signal, nil + } + if nextID == processAttachPickSentinel { + prepareTerminalForProcessPicker() + pickedID, pickErr := pickRunningPTY(c, client, sandboxID, "Attach to which shell session?") + if pickErr != nil { + return nil, "", pickErr + } + if pickedID == "" { + return nil, "", nil + } + nextID = pickedID + } + nextProc, getErr := client.GetProcess(c.Context, sandboxID, nextID) + if getErr != nil { + return nil, "", getErr + } + proc = nextProc + after = proc.Output.NewestSeq + stdinFlag = false + offsetRetries = 0 + } +} + +func processInitialAttachAfter(c *cli.Context, proc *api.ProcessDetails, noFollow bool) int64 { + after := c.Int64("after") + if noFollow || c.IsSet("after") || proc == nil { + return after + } + return proc.Output.NewestSeq +} + +const processAttachPickSentinel = "__createos_pick_pty__" +const processAttachRetryPrefix = "__createos_retry_after__:" + +func attachProcessOnce(c *cli.Context, client *api.SandboxClient, sandboxID, ref string, proc *api.ProcessDetails, after int64, noFollow bool, stdinFlag bool) (*int, string, string, error) { + if proc == nil { + return nil, "", "", fmt.Errorf("process details are missing") + } + fd := stdinFD() + stdinInteractive := term.IsTerminal(fd) + sendInput := stdinFlag || (proc.Kind == "pty" && stdinInteractive && !noFollow) + ctx, cancel := context.WithCancel(c.Context) + defer cancel() + nextCh := make(chan string, 1) + if noFollow && proc.Output.NewestSeq <= after { + return nil, "", "", nil + } + if !output.IsJSON(c) && proc.Kind == "pty" && sendInput { + pterm.Fprintln(os.Stderr, pterm.Gray(fmt.Sprintf(" attached to %s (%s)", proc.ProcessID, refLabel(ref, sandboxID)))) + } + footer := sendInput && proc.Kind == "pty" && stdinInteractive + altScreen := footer && processForegroundUsesAltScreen(proc) + localAltScreen := false + if sendInput && proc.Kind == "pty" && stdinInteractive { + oldState, rawErr := term.MakeRaw(fd) + if rawErr != nil { + return nil, "", "", fmt.Errorf("could not switch terminal to raw mode: %w", rawErr) + } + if altScreen { + enterLocalAltScreen() + localAltScreen = true + } + if footer && !altScreen { + applyPTYFooter(proc.ProcessID) + } + defer func() { + if footer { + clearPTYFooter() + } + if altScreen || localAltScreen { + leaveLocalAltScreen() + } + if restoreErr := term.Restore(fd, oldState); restoreErr != nil { + _ = restoreErr + } + }() //nolint:errcheck + sendCurrentProcessResize(c.Context, client, sandboxID, proc.ProcessID, footer && !altScreen) + stopResize := watchWindowSize(func() { + sendCurrentProcessResize(c.Context, client, sandboxID, proc.ProcessID, footer && !altScreen) + if footer && !altScreen { + applyPTYFooter(proc.ProcessID) + } + }) + defer stopResize() + if altScreen { + pulseCurrentProcessResize(ctx, client, sandboxID, proc.ProcessID) + } + requestPTYPromptRedraw(ctx, client, sandboxID, proc, after, altScreen) + } + if sendInput { + go copyProcessInput(ctx, cancel, nextCh, client, sandboxID, proc.ProcessID, proc.Kind == "pty") + } + var exitCode *int + var exitSignal string + nextID := "" + retryAfter := int64(-1) + targetNewest := proc.Output.NewestSeq + err := client.ConnectProcess(ctx, sandboxID, proc.ProcessID, after, func(ev api.ProcessOutputEvent) { + switch ev.Type { + case "data": + data := writeProcessOutput(ev) + if footer { + enteredAltScreen := processOutputEntersAltScreen(data) + leftAltScreen := processOutputLeavesAltScreen(data) + switch { + case enteredAltScreen: + altScreen = true + localAltScreen = false + clearPTYFooter() + case leftAltScreen: + altScreen = false + localAltScreen = false + applyPTYFooter(proc.ProcessID) + case !altScreen: + applyPTYFooter(proc.ProcessID) + } + } + if noFollow && targetNewest > 0 && ev.Seq >= targetNewest { + cancel() + } + case "exit": + exitCode = ev.ExitCode + exitSignal = ev.Signal + if noFollow { + cancel() + } + case "error": + if isProcessOutputOffsetExpired(ev.Error) { + retryAfter = 0 + if ev.OldestAvailableSeq > 0 { + retryAfter = ev.OldestAvailableSeq + } + } else if ev.Error != "" { + pterm.Error.Println(ev.Error) + } + cancel() + } + }) + if errors.Is(err, context.Canceled) { + select { + case nextID = <-nextCh: + default: + } + if retryAfter >= 0 { + nextID = processAttachRetryID(retryAfter) + } + return exitCode, exitSignal, nextID, nil + } + if isProcessOutputOffsetExpiredError(err) { + return exitCode, exitSignal, processAttachRetryID(0), nil + } + return exitCode, exitSignal, "", err +} + +func processAttachRetryID(after int64) string { + if after < 0 { + after = 0 + } + return fmt.Sprintf("%s%d", processAttachRetryPrefix, after) +} + +func processAttachRetryAfter(id string) (int64, bool) { + raw, ok := strings.CutPrefix(id, processAttachRetryPrefix) + if !ok { + return 0, false + } + after, err := strconv.ParseInt(raw, 10, 64) + if err != nil || after < 0 { + return 0, true + } + return after, true +} + +func isProcessOutputOffsetExpiredError(err error) bool { + if err == nil { + return false + } + var apiErr *api.APIError + if errors.As(err, &apiErr) { + return isProcessOutputOffsetExpired(apiErr.Message) + } + return isProcessOutputOffsetExpired(err.Error()) +} + +func isProcessOutputOffsetExpired(msg string) bool { + normalized := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(msg), "_", " ")) + return strings.Contains(normalized, "output offset expired") +} + +func copyProcessInput(ctx context.Context, detach context.CancelFunc, nextCh chan<- string, client *api.SandboxClient, sandboxID, processID string, pty bool) { + buf := make([]byte, 32*1024) + for { + n, err := os.Stdin.Read(buf) + if n > 0 { + chunk := buf[:n] + if pty { + if before, action, found := splitOnPTYShortcut(chunk); found { + if len(before) > 0 { + encoded := base64.StdEncoding.EncodeToString(before) + _, _ = client.WriteProcessInput(ctx, sandboxID, processID, api.ProcessInputRequest{DataBase64: encoded}) //nolint:errcheck + } + handlePTYShortcut(ctx, client, sandboxID, processID, action, nextCh) + detach() + return + } + } + encoded := base64.StdEncoding.EncodeToString(chunk) + _, _ = client.WriteProcessInput(ctx, sandboxID, processID, api.ProcessInputRequest{DataBase64: encoded}) //nolint:errcheck + } + if err != nil { + return + } + } +} + +func splitOnPTYShortcut(data []byte) (before []byte, action string, found bool) { + for i, b := range data { + switch b { + case 0x1d: // Ctrl-] + return data[:i], "detach", true + case 0x0e: // Ctrl-N + return data[:i], "new", true + case 0x10: // Ctrl-P + return data[:i], "pick", true + } + } + return data, "", false +} + +func handlePTYShortcut(ctx context.Context, client *api.SandboxClient, sandboxID, processID, action string, nextCh chan<- string) { + clearPTYFooter() + switch action { + case "detach": + fmt.Fprint(os.Stderr, "\r\nDetached. The shell is still running. Reattach with:\r\n") //nolint:errcheck + fmt.Fprintf(os.Stderr, " createos sandbox process attach %s %s\r\n", sandboxID, processID) + case "new": + nextID, err := createSiblingPTY(ctx, client, sandboxID) + if err != nil { + fmt.Fprintf(os.Stderr, "\r\nCould not create a new shell session: %v\r\n", err) //nolint:errcheck + return + } + fmt.Fprintf(os.Stderr, "\r\nCreated %s. Switching...\r\n", nextID) //nolint:errcheck + sendNextProcessID(nextCh, nextID) + case "pick": + fmt.Fprint(os.Stderr, "\r\nSwitching shell session...\r\n") //nolint:errcheck + sendNextProcessID(nextCh, processAttachPickSentinel) + } +} + +func createSiblingPTY(ctx context.Context, client *api.SandboxClient, sandboxID string) (string, error) { + rows, cols := currentTerminalRowsCols(true) + proc, err := client.CreateProcess(ctx, sandboxID, api.ProcessCreateRequest{PTY: &api.ProcessPTYOptions{Rows: rows, Cols: cols}}) + if err != nil { + return "", err + } + return proc.ProcessID, nil +} + +func sendNextProcessID(nextCh chan<- string, processID string) { + select { + case nextCh <- processID: + default: + } +} + +func sendCurrentProcessResize(ctx context.Context, client *api.SandboxClient, sandboxID, processID string, reserveFooter bool) { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + cols, rows, err := term.GetSize(fd) + if err != nil { + return + } + if reserveFooter && rows > 1 { + rows-- + } + _ = client.ResizeProcessPTY(ctx, sandboxID, processID, api.ProcessResizeRequest{Rows: rows, Cols: cols}) //nolint:errcheck +} + +func pulseCurrentProcessResize(ctx context.Context, client *api.SandboxClient, sandboxID, processID string) { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + cols, rows, err := term.GetSize(fd) + if err != nil || rows <= 2 || cols <= 0 { + return + } + _ = client.ResizeProcessPTY(ctx, sandboxID, processID, api.ProcessResizeRequest{Rows: rows - 1, Cols: cols}) //nolint:errcheck + time.AfterFunc(80*time.Millisecond, func() { + _ = client.ResizeProcessPTY(ctx, sandboxID, processID, api.ProcessResizeRequest{Rows: rows, Cols: cols}) //nolint:errcheck + }) +} + +func applyPTYFooter(processID string) { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + cols, rows, err := term.GetSize(fd) + if err != nil || rows <= 1 || cols <= 0 { + return + } + bodyRows := rows - 1 + text := processFooterText(processID) + if len(text) > cols { + text = text[:cols] + } + if len(text) < cols { + text += strings.Repeat(" ", cols-len(text)) + } + fmt.Fprintf(os.Stderr, "\x1b7\x1b[0m\x1b[1;%dr\x1b[%d;1H\x1b[7m%s\x1b[0m\x1b8", bodyRows, rows, text) //nolint:errcheck +} + +func processFooterText(processID string) string { + return fmt.Sprintf(" createos %s | Ctrl-] detach | Ctrl-N new | Ctrl-P switch | exit close ", processFooterProcessID(processID)) +} + +func processFooterProcessID(processID string) string { + const maxLen = 18 + if len(processID) <= maxLen { + return processID + } + return processID[:10] + "…" + processID[len(processID)-5:] +} + +func clearPTYFooter() { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + _, rows, err := term.GetSize(fd) + if err != nil || rows <= 0 { + return + } + fmt.Fprintf(os.Stderr, "\x1b7\x1b[r\x1b[%d;1H\x1b[2K\x1b8", rows) //nolint:errcheck +} + +func enterLocalAltScreen() { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + fmt.Fprint(os.Stderr, "\x1b[?1049h\x1b[0m\x1b[2J\x1b[H") //nolint:errcheck +} + +func leaveLocalAltScreen() { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + fmt.Fprint(os.Stderr, "\x1b[0m\x1b[?25h\x1b[?1049l") //nolint:errcheck +} + +func prepareTerminalForProcessPicker() { + fd := stdinFD() + if !term.IsTerminal(fd) { + return + } + fmt.Fprint(os.Stderr, "\x1b[0m\x1b[?25h\x1b[r\x1b[?1049l\x1b[2J\x1b[H") //nolint:errcheck +} + +func processOutputEntersAltScreen(data []byte) bool { + return bytes.Contains(data, []byte("\x1b[?1049h")) || + bytes.Contains(data, []byte("\x1b[?1047h")) || + bytes.Contains(data, []byte("\x1b[?47h")) +} + +func processOutputLeavesAltScreen(data []byte) bool { + return bytes.Contains(data, []byte("\x1b[?1049l")) || + bytes.Contains(data, []byte("\x1b[?1047l")) || + bytes.Contains(data, []byte("\x1b[?47l")) +} + +func processForegroundUsesAltScreen(proc *api.ProcessDetails) bool { + switch processCommandName(processForegroundCommand(proc)) { + case "btop", "top", "htop", "vim", "vi", "nvim", "nano", "less", "more", "man", "ssh", "watch": + return true + default: + return false + } +} + +func requestPTYPromptRedraw(ctx context.Context, client *api.SandboxClient, sandboxID string, proc *api.ProcessDetails, after int64, altScreen bool) { + if proc == nil || proc.Kind != "pty" || altScreen || after < proc.Output.NewestSeq || !processForegroundIsShell(proc) { + return + } + encoded := base64.StdEncoding.EncodeToString([]byte{0x0c}) // Ctrl-L: redraw readline shells without submitting a command. + _, _ = client.WriteProcessInput(ctx, sandboxID, proc.ProcessID, api.ProcessInputRequest{DataBase64: encoded}) //nolint:errcheck +} + +func processForegroundIsShell(proc *api.ProcessDetails) bool { + if proc == nil || proc.Kind != "pty" { + return false + } + cmd := processForegroundCommand(proc) + if cmd == "" && strings.TrimSpace(proc.Cmd) == "" { + return true + } + switch processCommandName(cmd) { + case "bash", "sh", "zsh", "fish", "dash", "ash", "ksh": + return true + default: + return false + } +} + +func processForegroundCommand(proc *api.ProcessDetails) string { + if proc == nil { + return "" + } + if proc.Foreground != nil && strings.TrimSpace(proc.Foreground.Cmd) != "" { + return strings.TrimSpace(proc.Foreground.Cmd) + } + return strings.TrimSpace(proc.Cmd) +} + +func processCommandName(cmd string) string { + cmd = strings.TrimSpace(cmd) + if cmd == "" { + return "" + } + name := cmd + if fields := strings.Fields(cmd); len(fields) > 0 { + name = fields[0] + } + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[i+1:] + } + return name +} + +func writeProcessOutput(ev api.ProcessOutputEvent) []byte { + if ev.DataBase64 == "" { + return nil + } + data, err := base64.StdEncoding.DecodeString(ev.DataBase64) + if err != nil { + return nil + } + if ev.Stream == "stderr" { + _, _ = os.Stderr.Write(data) //nolint:errcheck + return data + } + _, _ = os.Stdout.Write(data) //nolint:errcheck + return data +} + +func processInputBase64(c *cli.Context) (string, error) { + text := processStringFlag(c, "input", "text") + file := processStringFlag(c, "input", "file") + encodedFlag := processStringFlag(c, "input", "base64") + set := 0 + if text != "" { + set++ + } + if file != "" { + set++ + } + if encodedFlag != "" { + set++ + } + if set != 1 { + return "", fmt.Errorf("provide exactly one of --text, --file, or --base64") + } + if encodedFlag != "" { + return strings.TrimSpace(encodedFlag), nil + } + if text != "" { + return base64.StdEncoding.EncodeToString([]byte(text)), nil + } + var data []byte + var err error + if file == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(file) // #nosec G304 -- user supplied file path to send as process input + } + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(data), nil +} + +func waitForManagedProcess(ctx context.Context, client *api.SandboxClient, sandboxID, processID string, all bool, timeout time.Duration) (*api.ProcessDetails, error) { + deadline := time.Time{} + if timeout > 0 { + deadline = time.Now().Add(timeout) + } + for { + pollTimeout := int64(30000) + if timeout > 0 { + remaining := time.Until(deadline) + if remaining <= 0 { + return nil, fmt.Errorf("wait timed out") + } + if remaining < 30*time.Second { + pollTimeout = durationMs(remaining) + } + } + proc, err := client.WaitProcess(ctx, sandboxID, processID, all, pollTimeout) + if err == nil { + return proc, nil + } + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 408 && timeout == 0 { + continue + } + return nil, err + } +} + +func printProcessCreated(proc *api.ProcessDetails) { + pterm.Success.Printf("Started %s (%s).\n", proc.ProcessID, proc.Kind) +} + +func printProcessDetails(proc *api.ProcessDetails) { + if proc == nil { + return + } + cyan := pterm.NewStyle(pterm.FgCyan) + fmt.Println(proc.ProcessID) + cyan.Printf("Kind: ") + fmt.Println(proc.Kind) + cyan.Printf("PID: ") + fmt.Println(proc.PID) + cyan.Printf("State: ") + fmt.Println(processStateLabel(*proc)) + cyan.Printf("Exit: ") + fmt.Println(processExitLabel(*proc)) + cyan.Printf("Command: ") + fmt.Println(processCommandLabel(*proc, 0)) + if proc.Foreground != nil && strings.TrimSpace(proc.Foreground.Cmd) != "" { + cyan.Printf("Foreground: ") + fmt.Printf("%s (pid %d)\n", strings.TrimSpace(proc.Foreground.Cmd), proc.Foreground.PID) + } + if proc.Cwd != "" { + cyan.Printf("CWD: ") + fmt.Println(proc.Cwd) + } + cyan.Printf("Output: ") + fmt.Printf("%d bytes, seq %d..%d\n", proc.Output.Bytes, proc.Output.OldestSeq, proc.Output.NewestSeq) + cyan.Printf("Created: ") + fmt.Println(processLocalRFC3339(proc.CreatedAt)) + if proc.FinishedAt != nil { + cyan.Printf("Finished: ") + fmt.Println(processLocalRFC3339(*proc.FinishedAt)) + } +} + +func processLocalDateTime(t time.Time) string { + return t.In(time.Local).Format("2006-01-02 15:04:05") +} + +func processLocalClock(t time.Time) string { + return t.In(time.Local).Format("15:04:05") +} + +func processLocalRFC3339(t time.Time) string { + return t.In(time.Local).Format(time.RFC3339) +} + +func processCommandLabel(proc api.ProcessDetails, maxLen int) string { + if proc.Foreground != nil && strings.TrimSpace(proc.Foreground.Cmd) != "" { + return truncateProcessLabel(strings.TrimSpace(proc.Foreground.Cmd), maxLen) + } + cmd := strings.TrimSpace(proc.Cmd) + if cmd == "" && proc.Kind == "pty" { + cmd = "shell" + } + if cmd == "" { + cmd = "-" + } + if len(proc.Args) > 0 { + cmd += " " + strings.Join(proc.Args, " ") + } + return truncateProcessLabel(cmd, maxLen) +} + +func truncateProcessLabel(cmd string, maxLen int) string { + if maxLen > 0 && len(cmd) > maxLen { + if maxLen <= 1 { + return "…" + } + return cmd[:maxLen-1] + "…" + } + return cmd +} + +func processStateLabel(proc api.ProcessDetails) string { + if proc.TreeExited { + return proc.State + " (all stopped)" + } + if proc.LeaderExited { + return proc.State + " (command exited)" + } + return proc.State +} + +func processExitLabel(proc api.ProcessDetails) string { + if proc.ExitCode != nil { + return strconv.Itoa(*proc.ExitCode) + } + if proc.Signal != "" { + return proc.Signal + } + return "-" +} + +func exitFromProcess(exitCode *int, signal string) error { + if exitCode != nil && *exitCode != 0 { + os.Exit(*exitCode) + } + if exitCode == nil && signal != "" { + return fmt.Errorf("process exited from signal %s", signal) + } + return nil +} + +func durationMs(d time.Duration) int64 { + if d <= 0 { + return 0 + } + return int64(d / time.Millisecond) +} + +func stdinFD() int { + return int(os.Stdin.Fd()) // #nosec G115 -- file descriptor values fit in int on supported platforms +} + +func processStringFlag(c *cli.Context, subcommand, name string) string { + if v := c.String(name); v != "" { + return v + } + return rawProcessFlagValue(subcommand, name) +} + +func processBoolFlag(c *cli.Context, subcommand, name string) bool { + if c.Bool(name) { + return true + } + raw := rawProcessFlagValue(subcommand, name) + if raw == "" { + return rawProcessFlagPresent(subcommand, name) + } + v, err := strconv.ParseBool(raw) + return err == nil && v +} + +func processIntFlag(c *cli.Context, subcommand, name string) int { + if v := c.Int(name); v != 0 { + return v + } + raw := rawProcessFlagValue(subcommand, name) + if raw == "" { + return 0 + } + v, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + return v +} + +func processDurationFlag(c *cli.Context, subcommand, name string) time.Duration { + if v := c.Duration(name); v != 0 { + return v + } + raw := rawProcessFlagValue(subcommand, name) + if raw == "" { + return 0 + } + v, err := time.ParseDuration(raw) + if err != nil { + return 0 + } + return v +} + +func rawProcessFlagPresent(subcommand, name string) bool { + target := "--" + name + for i := processSubcommandArgIndex(subcommand) + 1; i > 0 && i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--" { + break + } + if arg == target { + return true + } + } + return false +} + +func rawProcessFlagValue(subcommand, name string) string { + start := processSubcommandArgIndex(subcommand) + if start < 0 { + return "" + } + target := "--" + name + prefix := target + "=" + for i := start + 1; i < len(os.Args); i++ { + arg := os.Args[i] + if arg == "--" { + break + } + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + if arg == target && i+1 < len(os.Args) { + next := os.Args[i+1] + if !strings.HasPrefix(next, "-") { + return next + } + } + } + return "" +} + +func processSubcommandArgIndex(subcommand string) int { + for i, arg := range os.Args { + if arg == subcommand { + return i + } + } + return -1 +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go index 2e63557..0a3d087 100644 --- a/cmd/sandbox/sandbox.go +++ b/cmd/sandbox/sandbox.go @@ -23,6 +23,7 @@ func NewSandboxCommand() *cli.Command { newResumeCommand(), newForkCommand(), newExecCommand(), + newProcessCommand(), newPushCommand(), newPullCommand(), newShellCommand(), diff --git a/cmd/sandbox/shell.go b/cmd/sandbox/shell.go index 8323d2a..31e3f09 100644 --- a/cmd/sandbox/shell.go +++ b/cmd/sandbox/shell.go @@ -31,8 +31,16 @@ func newShellCommand() *cli.Command { Aliases: []string{"sh"}, Usage: "Open an interactive shell inside a sandbox", ArgsUsage: "[]", - Description: `Open a real terminal session inside a sandbox. -Works with tools that need a TTY — vim, htop, bash prompts. + Description: `Open an immediate interactive terminal inside a sandbox. + +Use this when you just want to work in a shell now. It gives you a real +TTY for bash prompts, vim, htop, btop, and other terminal apps. + +This command is attached to your current CLI process: when you exit or +disconnect, there is no managed process ID to list or reconnect later. +Use 'sandbox process shell' when you want a persistent shell session +that can be detached, listed, and reattached. +Use 'sandbox exec' for quick non-interactive commands. By default this opens a PTY directly through the control plane — no SSH keys, no sshd setup. Your existing API token is the only auth. diff --git a/cmd/sandbox/template.go b/cmd/sandbox/template.go index 1fad088..ca51eaa 100644 --- a/cmd/sandbox/template.go +++ b/cmd/sandbox/template.go @@ -152,7 +152,7 @@ func runTemplateList(c *cli.Context) error { table = append(table, []string{ t.Name, t.ID, t.Status, humanBytes(t.Ext4SizeBytes), - t.CreatedAt.Format("2006-01-02 15:04"), + t.CreatedAt.Local().Format("2006-01-02 15:04"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(table).Render() //nolint:errcheck @@ -208,9 +208,9 @@ func runTemplateShow(c *cli.Context) error { if t.Ext4SizeBytes > 0 { row("Size", humanBytes(t.Ext4SizeBytes)) } - row("Created", t.CreatedAt.Format("2006-01-02 15:04:05")) + row("Created", t.CreatedAt.Local().Format("2006-01-02 15:04:05")) if t.BuiltAt != nil { - row("Built", t.BuiltAt.Format("2006-01-02 15:04:05")) + row("Built", t.BuiltAt.Local().Format("2006-01-02 15:04:05")) } switch t.Status { case "failed": @@ -420,7 +420,7 @@ func resolveTemplateRefArg(c *cli.Context, client *api.SandboxClient, prompt str options := make([]string, 0, len(tpls)) byOpt := make(map[string]string, len(tpls)) for _, t := range tpls { - opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Format("2006-01-02 15:04")) + opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Local().Format("2006-01-02 15:04")) options = append(options, opt) byOpt[opt] = t.Name } @@ -449,7 +449,7 @@ func pickTemplatesForDelete(c *cli.Context, client *api.SandboxClient) ([]string options := make([]string, 0, len(tpls)) byOpt := make(map[string]string, len(tpls)) for _, t := range tpls { - opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Format("2006-01-02 15:04")) + opt := fmt.Sprintf("%s (%s, %s)", t.Name, t.Status, t.CreatedAt.Local().Format("2006-01-02 15:04")) options = append(options, opt) byOpt[opt] = t.Name } diff --git a/cmd/templates/info.go b/cmd/templates/info.go index 65faf0f..534b946 100644 --- a/cmd/templates/info.go +++ b/cmd/templates/info.go @@ -65,7 +65,7 @@ func newTemplatesInfoCommand() *cli.Command { fmt.Println(tmpl.Status) cyan.Printf(" Created: ") - fmt.Println(tmpl.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(tmpl.CreatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Println() return nil diff --git a/cmd/vms/get.go b/cmd/vms/get.go index e12a4a6..44447b8 100644 --- a/cmd/vms/get.go +++ b/cmd/vms/get.go @@ -67,10 +67,10 @@ func newVMGetCommand() *cli.Command { fmt.Println(len(vm.Inputs.SSHKeys)) cyan.Printf("Created At: ") - fmt.Println(vm.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(vm.CreatedAt.Local().Format("2006-01-02 15:04:05")) cyan.Printf("Updated At: ") - fmt.Println(vm.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Println(vm.UpdatedAt.Local().Format("2006-01-02 15:04:05")) fmt.Println() return nil diff --git a/cmd/vms/list.go b/cmd/vms/list.go index 9500031..ae29735 100644 --- a/cmd/vms/list.go +++ b/cmd/vms/list.go @@ -49,7 +49,7 @@ func newVMListCommand() *cli.Command { vm.Status, ip, "-", - vm.CreatedAt.Format("2006-01-02 15:04:05"), + vm.CreatedAt.Local().Format("2006-01-02 15:04:05"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck diff --git a/cmd/webhooks/get.go b/cmd/webhooks/get.go index 8021c27..6e66232 100644 --- a/cmd/webhooks/get.go +++ b/cmd/webhooks/get.go @@ -48,7 +48,7 @@ func newWebhooksGetCommand() *cli.Command { } fmt.Printf("%s %s\n", label.Sprint("Events:"), events) fmt.Printf("%s %d\n", label.Sprint("Failures:"), ep.FailureCount) - fmt.Printf("%s %s\n", label.Sprint("Created:"), ep.CreatedAt.Format("2006-01-02 15:04:05 UTC")) + fmt.Printf("%s %s\n", label.Sprint("Created:"), ep.CreatedAt.Local().Format("2006-01-02 15:04:05 MST")) fmt.Println() if len(result.Deliveries) == 0 { @@ -66,7 +66,7 @@ func newWebhooksGetCommand() *cli.Command { d.EventAction, deliveryStatusIcon(d.Status), fmt.Sprintf("%d", d.Attempts), - d.CreatedAt.Format("2006-01-02 15:04"), + d.CreatedAt.Local().Format("2006-01-02 15:04"), }) } _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() //nolint:errcheck diff --git a/internal/api/sandbox_process.go b/internal/api/sandbox_process.go new file mode 100644 index 0000000..af28bc3 --- /dev/null +++ b/internal/api/sandbox_process.go @@ -0,0 +1,220 @@ +package api + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" +) + +// CreateProcess creates a managed pipe process or PTY in a sandbox. +func (c *SandboxClient) CreateProcess(ctx context.Context, sandboxID string, req ProcessCreateRequest) (*ProcessDetails, error) { + var envelope Response[ProcessDetails] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetBody(req). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/processes") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ListProcesses lists managed processes and PTYs in a sandbox. +func (c *SandboxClient) ListProcesses(ctx context.Context, sandboxID string) ([]ProcessDetails, error) { + var envelope Response[ProcessListResponse] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/processes") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return envelope.Data.Processes, nil +} + +// GetProcess inspects one managed process. +func (c *SandboxClient) GetProcess(ctx context.Context, sandboxID, processID string) (*ProcessDetails, error) { + var envelope Response[ProcessDetails] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetResult(&envelope). + Get("/v1/sandboxes/{id}/processes/{process_id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// ConnectProcess replays and follows ordered output events. +func (c *SandboxClient) ConnectProcess(ctx context.Context, sandboxID, processID string, after int64, onEvent func(ProcessOutputEvent)) error { + req := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetDoNotParseResponse(true) + if after > 0 { + req.SetQueryParam("after", fmt.Sprintf("%d", after)) + } + resp, err := req.Get("/v1/sandboxes/{id}/processes/{process_id}/connect") + if err != nil { + return err + } + body := resp.RawBody() + defer func() { _ = body.Close() }() //nolint:errcheck + if resp.IsError() { + raw, readErr := io.ReadAll(body) + if readErr != nil { + raw = nil + } + return ParseAPIError(resp.StatusCode(), raw) + } + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var ev ProcessOutputEvent + if err := json.Unmarshal(line, &ev); err != nil { + continue + } + onEvent(ev) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("read process stream: %w", err) + } + return nil +} + +// WriteProcessInput writes ordered bytes to process stdin or a PTY. +func (c *SandboxClient) WriteProcessInput(ctx context.Context, sandboxID, processID string, req ProcessInputRequest) (*ProcessInputResponse, error) { + var envelope Response[ProcessInputResponse] + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetBody(req). + SetResult(&envelope). + Post("/v1/sandboxes/{id}/processes/{process_id}/input") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// CloseProcessStdin closes stdin for a pipe process. +func (c *SandboxClient) CloseProcessStdin(ctx context.Context, sandboxID, processID string) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + Post("/v1/sandboxes/{id}/processes/{process_id}/stdin/close") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// ResizeProcessPTY resizes a managed PTY. +func (c *SandboxClient) ResizeProcessPTY(ctx context.Context, sandboxID, processID string, req ProcessResizeRequest) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetBody(req). + Post("/v1/sandboxes/{id}/processes/{process_id}/resize") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// SignalProcess sends a signal to a process group or PTY foreground group. +func (c *SandboxClient) SignalProcess(ctx context.Context, sandboxID, processID string, req ProcessSignalRequest) error { + resp, err := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetBody(req). + Post("/v1/sandboxes/{id}/processes/{process_id}/signal") + if err != nil { + return err + } + if resp.IsError() { + return ParseAPIError(resp.StatusCode(), resp.Body()) + } + return nil +} + +// WaitProcess waits for the main command or every process it started. +func (c *SandboxClient) WaitProcess(ctx context.Context, sandboxID, processID string, all bool, timeoutMs int64) (*ProcessDetails, error) { + var envelope Response[ProcessDetails] + req := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetResult(&envelope) + if all { + req.SetQueryParam("scope", "tree") + } else { + req.SetQueryParam("scope", "leader") + } + if timeoutMs > 0 { + req.SetQueryParam("timeout_ms", fmt.Sprintf("%d", timeoutMs)) + } + resp, err := req.Get("/v1/sandboxes/{id}/processes/{process_id}/wait") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} + +// TerminateProcess terminates the complete managed process tree. +func (c *SandboxClient) TerminateProcess(ctx context.Context, sandboxID, processID string, graceMs int64) (*ProcessDetails, error) { + var envelope Response[ProcessDetails] + req := c.Client.R(). + SetContext(ctx). + SetPathParam("id", sandboxID). + SetPathParam("process_id", processID). + SetResult(&envelope) + if graceMs >= 0 { + req.SetQueryParam("grace_ms", fmt.Sprintf("%d", graceMs)) + } + resp, err := req.Delete("/v1/sandboxes/{id}/processes/{process_id}") + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, ParseAPIError(resp.StatusCode(), resp.Body()) + } + return &envelope.Data, nil +} diff --git a/internal/api/sandbox_process_types.go b/internal/api/sandbox_process_types.go new file mode 100644 index 0000000..6623606 --- /dev/null +++ b/internal/api/sandbox_process_types.go @@ -0,0 +1,89 @@ +package api + +import "time" + +// ProcessPTYOptions requests a persistent PTY instead of separate pipes. +type ProcessPTYOptions struct { + Rows int `json:"rows,omitempty"` + Cols int `json:"cols,omitempty"` +} + +// ProcessCreateRequest is the body of POST /v1/sandboxes/:id/processes. +// Omit PTY for a pipe process; include it for a managed PTY session. +type ProcessCreateRequest struct { + Cmd string `json:"cmd,omitempty"` + Args []string `json:"args,omitempty"` + Cwd string `json:"cwd,omitempty"` + Env map[string]string `json:"env,omitempty"` + PTY *ProcessPTYOptions `json:"pty,omitempty"` +} + +// ProcessOutputSummary describes retained output for a managed process. +type ProcessOutputSummary struct { + OldestSeq int64 `json:"oldest_seq"` + NewestSeq int64 `json:"newest_seq"` + Bytes int64 `json:"bytes"` +} + +// ProcessForeground describes the current foreground command for a PTY. +type ProcessForeground struct { + PID int `json:"pid"` + Cmd string `json:"cmd"` +} + +// ProcessDetails is returned by create/list/get/wait/terminate. +type ProcessDetails struct { + ProcessID string `json:"process_id"` + Kind string `json:"kind"` + Cmd string `json:"cmd,omitempty"` + Args []string `json:"args,omitempty"` + Cwd string `json:"cwd,omitempty"` + Foreground *ProcessForeground `json:"foreground,omitempty"` + PID int `json:"pid"` + State string `json:"state"` + LeaderExited bool `json:"leader_exited"` + TreeExited bool `json:"tree_exited"` + CreatedAt time.Time `json:"created_at"` + FinishedAt *time.Time `json:"finished_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Signal string `json:"signal,omitempty"` + Output ProcessOutputSummary `json:"output"` +} + +// ProcessListResponse is the body of GET /v1/sandboxes/:id/processes. +type ProcessListResponse struct { + Processes []ProcessDetails `json:"processes"` +} + +// ProcessInputRequest writes bytes to stdin or a PTY. +type ProcessInputRequest struct { + DataBase64 string `json:"data_base64"` +} + +// ProcessInputResponse confirms ordered input delivery. +type ProcessInputResponse struct { + InputSeq int64 `json:"input_seq"` +} + +// ProcessResizeRequest resizes a managed PTY. +type ProcessResizeRequest struct { + Rows int `json:"rows"` + Cols int `json:"cols"` +} + +// ProcessSignalRequest sends a signal to a process group / PTY foreground group. +type ProcessSignalRequest struct { + Signal string `json:"signal"` +} + +// ProcessOutputEvent is one NDJSON event from /connect. +type ProcessOutputEvent struct { + Type string `json:"type"` + Seq int64 `json:"seq,omitempty"` + Stream string `json:"stream,omitempty"` + DataBase64 string `json:"data_base64,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Signal string `json:"signal,omitempty"` + Error string `json:"error,omitempty"` + OldestAvailableSeq int64 `json:"oldest_available_seq,omitempty"` +} diff --git a/internal/api/types.go b/internal/api/types.go index 27f5eeb..f2e8c6d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -109,6 +109,15 @@ func ParseAPIError(statusCode int, body []byte) *APIError { msg = strings.Join(parts, "; ") } } + // 3. generic object with an "error" string, used by the process API. + if msg == "" { + var fields map[string]any + if err := json.Unmarshal(envelope.Data, &fields); err == nil { + if s, ok := fields["error"].(string); ok && s != "" { + msg = strings.ReplaceAll(s, "_", " ") + } + } + } } if msg == "" { msg = fmt.Sprintf("request failed with status %d", statusCode) diff --git a/internal/ui/catalog_list.go b/internal/ui/catalog_list.go index ed8e52f..25d4ce4 100644 --- a/internal/ui/catalog_list.go +++ b/internal/ui/catalog_list.go @@ -329,7 +329,7 @@ func (m catalogListModel) catalogDetailRender() string { s += "\n" s += labelStyle.Render("Price ") + valueStyle.Render(formatCredits(skill.Amount)) + "\n" s += labelStyle.Render("Categories ") + valueStyle.Render(categories) + "\n" - s += labelStyle.Render("Created ") + valueStyle.Render(skill.CreatedAt.Format("January 2, 2006")) + "\n" + s += labelStyle.Render("Created ") + valueStyle.Render(skill.CreatedAt.Local().Format("January 2, 2006")) + "\n" s += "\n" s += labelStyle.Render("Use cases") + "\n" for _, line := range strings.Split(wordWrap(useCases, 60), "\n") { diff --git a/internal/ui/skills_list.go b/internal/ui/skills_list.go index 569dc11..5839c0b 100644 --- a/internal/ui/skills_list.go +++ b/internal/ui/skills_list.go @@ -248,7 +248,7 @@ func (m skillsListModel) detailViewRender() string { s += dividerStyle.Render(strings.Repeat("─", 70)) + "\n" s += "\n" s += labelStyle.Render("Categories ") + valueStyle.Render(categories) + "\n" - s += labelStyle.Render("Created ") + valueStyle.Render(skill.CreatedAt.Format("January 2, 2006")) + "\n" + s += labelStyle.Render("Created ") + valueStyle.Render(skill.CreatedAt.Local().Format("January 2, 2006")) + "\n" s += "\n" s += labelStyle.Render("Overview") + "\n" for _, line := range strings.Split(wordWrap(overview, 60), "\n") { From 560e81754c7f2d040d5c940ad45bfac0da8483dc Mon Sep 17 00:00:00 2001 From: Bhautik Date: Wed, 19 Aug 2026 17:34:48 +0530 Subject: [PATCH 2/2] Polish sandbox process UX and remove skills command --- CLAUDE.md | 3 +- README.md | 19 ++--- cmd/ask/agent.md | 1 - cmd/root/root.go | 33 ++++++++- cmd/sandbox/create.go | 2 +- cmd/sandbox/process.go | 158 ++++++++++++++++++++++++++++++++-------- cmd/skills/catalog.go | 39 ---------- cmd/skills/purchased.go | 35 --------- cmd/skills/skills.go | 17 ----- 9 files changed, 165 insertions(+), 142 deletions(-) delete mode 100644 cmd/skills/catalog.go delete mode 100644 cmd/skills/purchased.go delete mode 100644 cmd/skills/skills.go diff --git a/CLAUDE.md b/CLAUDE.md index 36dc7cb..405e4b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,13 +53,12 @@ private helper, test-only — is **not** a shared surface, so skip the mesh for cmd/ auth/ login, logout, whoami commands projects/ projects subcommands (list, get, delete) - skills/ skills subcommands (catalog, purchased) root/ app wiring, Before hook, default action internal/ api/ resty client, types, all API methods config/ token storage (~/.createos/.token) intro/ ASCII banner - ui/ interactive TUI components (skills catalog) + ui/ interactive TUI components main.go entry point — error display only ``` diff --git a/README.md b/README.md index e7bd300..f11cfd5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Your intelligent infrastructure CLI ``` -The official command-line interface for [CreateOS](https://createos.nodeops.network?utm_source=createos-cli) — manage your projects and skills from the terminal. +The official command-line interface for [CreateOS](https://createos.nodeops.network?utm_source=createos-cli) — manage your projects from the terminal. ## Installation @@ -338,7 +338,7 @@ Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox she | `createos sandbox process start -- ` | Start a managed command and print its process ID | | `createos sandbox process shell ` | Start a persistent shell session that can be reattached | | `createos sandbox process attach ` | Reconnect to process output or a shell session | -| `createos sandbox process attach ` | Pick a running shell session and attach/switch to it | +| `createos sandbox process attach ` | Pick a running process or shell session and attach to it | | `createos sandbox process list ` | List managed processes and shell sessions | | `createos sandbox process get ` | Show details for one managed process | | `createos sandbox process input ` | Write input to a process or shell session | @@ -348,7 +348,7 @@ Use `sandbox exec` for quick non-interactive one-shot commands. Use `sandbox she | `createos sandbox process wait ` | Wait for a managed process to exit | | `createos sandbox process stop ` | Stop a process and anything it started | -Managed shell attach shortcuts are fixed: `Ctrl-]` detaches, `Ctrl-N` creates a new shell and switches to it, and `Ctrl-P` picks another running shell session. +Interactive attach without a process ID shows running managed processes. Pick a PTY shell for an interactive terminal, or pick a pipe process to follow its stdout/stderr output. Managed shell shortcuts are fixed: `Ctrl-]` detaches, `Ctrl-N` creates a new shell and switches to it, and `Ctrl-P` opens the process picker. **Sandbox sub-resource commands:** @@ -386,13 +386,6 @@ Managed shell attach shortcuts are fixed: `Ctrl-]` detaches, `Ctrl-N` creates a | `createos webhooks suspend` | Suspend a webhook endpoint | | `createos webhooks resume` | Resume a suspended webhook endpoint| -### Skills - -| Command | Description | -| --------------------------- | -------------------------- | -| `createos skills catalog` | Browse the skills catalog | -| `createos skills purchased` | List your purchased skills | - ### Quick Actions | Command | Description | @@ -520,7 +513,7 @@ createos sandbox process run my-box -- npm test createos sandbox process start my-box -- python -m http.server 8000 createos sandbox process shell my-box createos sandbox process attach my-box proc_abc123 -createos sandbox process attach my-box # pick a running shell session +createos sandbox process attach my-box # pick a running process or shell session createos sandbox process ps my-box createos sandbox process input my-box proc_abc123 --text "hello\n" createos sandbox process signal my-box proc_abc123 SIGINT @@ -528,7 +521,7 @@ createos sandbox process wait my-box proc_abc123 --all createos sandbox process stop my-box proc_abc123 --grace 1s # Inside a managed shell session, the fixed bottom bar shows active shortcuts. # detach closes the local attach; new creates a shell and switches; -# switch picks another shell; `exit` closes the current shell. +# switch opens the process picker; `exit` closes the current shell. createos sandbox push my-box ./script.py /root/script.py createos sandbox pull my-box /root/output.csv ./output.csv createos sandbox tunnel my-box --local 8080 --remote 8000 @@ -638,7 +631,7 @@ createos environments list --project -o json | `--output, -o ` | Output format: `json` or `table` (default). Auto-json when piped. | | `--debug, -d` | Print HTTP request/response details (token is masked) | | `--api-url` | Override the API base URL | -| `--sandbox-api-url` | Override the sandbox (fc-spawn) base URL | +| `--sandbox-api-url` | Override the sandbox API base URL | | `--sandbox-gateway` | SSH gateway address (`host:port`) used by `sandbox shell --ssh` | ## Security diff --git a/cmd/ask/agent.md b/cmd/ask/agent.md index bfb2043..1fba062 100644 --- a/cmd/ask/agent.md +++ b/cmd/ask/agent.md @@ -23,7 +23,6 @@ Use the `bash` tool to run `createos` commands on behalf of the user. Always run - `createos vms` — Deploy, list, get, resize, ssh, reboot, terminate VM instances (`--vm `) - `createos oauth-clients` — List, create, get instructions, delete OAuth clients (`--client `) - `createos me` — List and revoke OAuth consents (`--client `) -- `createos skills` — Browse and list purchased skills - `createos init` — Link the current directory to a project - `createos status` — Show a project's health and deployment status - `createos open` — Open a project's live URL in the browser diff --git a/cmd/root/root.go b/cmd/root/root.go index c640814..8a21cdc 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -22,7 +22,6 @@ import ( "github.com/NodeOps-app/createos-cli/cmd/projects" "github.com/NodeOps-app/createos-cli/cmd/sandbox" "github.com/NodeOps-app/createos-cli/cmd/scale" - "github.com/NodeOps-app/createos-cli/cmd/skills" "github.com/NodeOps-app/createos-cli/cmd/status" "github.com/NodeOps-app/createos-cli/cmd/templates" "github.com/NodeOps-app/createos-cli/cmd/upgrade" @@ -62,7 +61,7 @@ func NewApp() *cli.App { }, &cli.StringFlag{ Name: "sandbox-api-url", - Usage: "Override the sandbox (fc-spawn) base URL", + Usage: "Override the sandbox API base URL", EnvVars: []string{"CREATEOS_SANDBOX_URL"}, Value: api.DefaultSandboxBaseURL, }, @@ -182,7 +181,6 @@ func NewApp() *cli.App { fmt.Println(" projects Manage projects") fmt.Println(" sandbox Manage sandboxes") fmt.Println(" scale Adjust replicas and resources") - fmt.Println(" skills Manage skills") fmt.Println(" status Show project health and deployment status") fmt.Println(" templates Browse and scaffold from project templates") fmt.Println(" vms Manage VM terminal instances") @@ -219,7 +217,6 @@ func NewApp() *cli.App { projects.NewProjectsCommand(), sandbox.NewSandboxCommand(), scale.NewScaleCommand(), - skills.NewSkillsCommand(), status.NewStatusCommand(), templates.NewTemplatesCommand(), upgrade.NewUpgradeCommand(), @@ -230,10 +227,38 @@ func NewApp() *cli.App { versioncmd.NewVersionCommand(), }, } + installTrailingHelpGuards(app.Commands) return app } +func installTrailingHelpGuards(commands []*cli.Command) { + for _, cmd := range commands { + if cmd == nil { + continue + } + if cmd.Action != nil { + original := cmd.Action + cmd.Action = func(c *cli.Context) error { + if argsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } + return original(c) + } + } + installTrailingHelpGuards(cmd.Subcommands) + } +} + +func argsRequestHelp(c *cli.Context) bool { + for _, arg := range c.Args().Slice() { + if arg == "-h" || arg == "--help" || arg == "help" { + return true + } + } + return false +} + // refreshOAuthSession exchanges the session's refresh token for a new // access token, updates the session in place, and persists it to // ~/.createos/.oauth. It returns the new access token. This is shared by diff --git a/cmd/sandbox/create.go b/cmd/sandbox/create.go index 5d2e022..b4c317c 100644 --- a/cmd/sandbox/create.go +++ b/cmd/sandbox/create.go @@ -19,7 +19,7 @@ func newCreateCommand() *cli.Command { Aliases: []string{"c"}, Usage: "Create a new sandbox", ArgsUsage: " ", - Description: `Create a new sandbox on fc-spawn. + Description: `Create a new sandbox. Examples: # Smallest possible sandbox diff --git a/cmd/sandbox/process.go b/cmd/sandbox/process.go index e0d21f4..268cb21 100644 --- a/cmd/sandbox/process.go +++ b/cmd/sandbox/process.go @@ -125,7 +125,7 @@ func newProcessAttachCommand() *cli.Command { return &cli.Command{ Name: "attach", Aliases: []string{"connect"}, - Usage: "Reconnect to a process, or pick a running shell session", + Usage: "Reconnect to a process, or pick a running managed process", ArgsUsage: " []", Description: `Reconnect to a managed process or shell session. @@ -134,9 +134,11 @@ Use this for process IDs created by 'process run', 'process start', or processes, attach follows retained stdout/stderr output. If you omit the process ID in an interactive terminal, attach shows a picker -of running shell sessions. Inside a shell session, the default shortcuts are -Ctrl-] to detach, Ctrl-N to create a new shell, and Ctrl-P to pick another -running shell session.`, +of running managed processes. Pick a PTY shell for interactive terminal +attach, or pick a pipe process to follow stdout/stderr output. + +Inside a PTY shell session, Ctrl-] detaches, Ctrl-N creates a new shell, +and Ctrl-P opens the process picker.`, Flags: []cli.Flag{ &cli.Int64Flag{Name: "after", Usage: "Replay output after this sequence number"}, &cli.BoolFlag{Name: "no-follow", Usage: "Replay retained output and exit"}, @@ -264,6 +266,10 @@ func processStartFlags() []cli.Flag { } func runProcessCreate(c *cli.Context, waitForExit bool, shellMode bool) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } + subcommand := c.Command.Name client, id, ref, err := processClientAndSandbox(c, true) if err != nil { return err @@ -278,14 +284,14 @@ func runProcessCreate(c *cli.Context, waitForExit bool, shellMode bool) error { } follow := waitForExit if !waitForExit { - follow = c.Bool("follow") + follow = processBoolFlag(c, subcommand, "follow") } - if c.Bool("no-follow") { + if processBoolFlag(c, subcommand, "no-follow") { follow = false } if output.IsJSON(c) { if waitForExit && follow { - final, waitErr := waitForManagedProcess(c.Context, client, id, proc.ProcessID, c.Bool("all"), c.Duration("timeout")) + final, waitErr := waitForManagedProcess(c.Context, client, id, proc.ProcessID, processBoolFlag(c, subcommand, "all"), processDurationFlag(c, subcommand, "timeout")) if waitErr != nil { return waitErr } @@ -302,7 +308,7 @@ func runProcessCreate(c *cli.Context, waitForExit bool, shellMode bool) error { if !waitForExit { printProcessCreated(proc) } - exitCode, signal, err := attachProcess(c, client, id, ref, proc, c.Int64("after"), false, false) + exitCode, signal, err := attachProcess(c, client, id, ref, proc, processInt64Flag(c, subcommand, "after"), false, false) if err != nil { return err } @@ -313,6 +319,10 @@ func runProcessCreate(c *cli.Context, waitForExit bool, shellMode bool) error { } func runProcessShell(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } + subcommand := c.Command.Name client, id, ref, err := processClientAndSandbox(c, false) if err != nil { return err @@ -325,13 +335,13 @@ func runProcessShell(c *cli.Context) error { Cmd: c.String("cmd"), Cwd: strings.TrimSpace(c.String("cwd")), Env: envs, - PTY: ptyOptionsFromFlags(c, !output.IsJSON(c) && !c.Bool("no-attach")), + PTY: ptyOptionsFromFlags(c, !output.IsJSON(c) && !processBoolFlag(c, subcommand, "no-attach")), } proc, err := client.CreateProcess(c.Context, id, req) if err != nil { return err } - if output.IsJSON(c) || c.Bool("no-attach") { + if output.IsJSON(c) || processBoolFlag(c, subcommand, "no-attach") { output.Render(c, proc, func() { printProcessCreated(proc) }) return nil } @@ -344,6 +354,10 @@ func runProcessShell(c *cli.Context) error { } func runProcessAttach(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } + subcommand := c.Command.Name var ( client *api.SandboxClient id string @@ -363,9 +377,9 @@ func runProcessAttach(c *cli.Context) error { } } if processID == "" { - processID, err = pickRunningPTY(c, client, id, "Attach to which shell session?") + processID, err = pickAttachableProcess(c, client, id, "Attach to which process?") if err != nil { - if errors.Is(err, errNoRunningPTY) && !c.Bool("no-follow") { + if errors.Is(err, errNoRunningProcess) && !processBoolFlag(c, subcommand, "no-follow") { proc, createErr := promptCreatePTYAndAttach(c, client, id) if createErr != nil { return createErr @@ -375,7 +389,7 @@ func runProcessAttach(c *cli.Context) error { return nil } printProcessCreated(proc) - _, _, attachErr := attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, proc, false), false, c.Bool("stdin")) + _, _, attachErr := attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, subcommand, proc, false), false, processBoolFlag(c, subcommand, "stdin")) return attachErr } return err @@ -389,12 +403,31 @@ func runProcessAttach(c *cli.Context) error { if err != nil { return err } - noFollow := c.Bool("no-follow") - _, _, err = attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, proc, noFollow), noFollow, c.Bool("stdin")) - return err + noFollow := processBoolFlag(c, subcommand, "no-follow") + exitCode, signal, err := attachProcess(c, client, id, ref, proc, processInitialAttachAfter(c, subcommand, proc, noFollow), noFollow, processBoolFlag(c, subcommand, "stdin")) + if err != nil { + return err + } + if !noFollow && proc.Kind != "pty" { + printProcessAttachExit(exitCode, signal) + return exitFromProcess(exitCode, signal) + } + return nil +} + +func processArgsRequestHelp(c *cli.Context) bool { + for _, arg := range c.Args().Slice() { + if arg == "-h" || arg == "--help" || arg == "help" { + return true + } + } + return false } func runProcessList(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, _, err := processClientAndSandbox(c, false) if err != nil { return err @@ -427,6 +460,9 @@ func runProcessList(c *cli.Context) error { } func runProcessGet(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -440,6 +476,9 @@ func runProcessGet(c *cli.Context) error { } func runProcessInput(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -459,6 +498,9 @@ func runProcessInput(c *cli.Context) error { } func runProcessCloseStdin(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -471,6 +513,9 @@ func runProcessCloseStdin(c *cli.Context) error { } func runProcessResize(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -487,6 +532,9 @@ func runProcessResize(c *cli.Context) error { } func runProcessSignal(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -506,6 +554,9 @@ func runProcessSignal(c *cli.Context) error { } func runProcessWait(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -519,6 +570,9 @@ func runProcessWait(c *cli.Context) error { } func runProcessStop(c *cli.Context) error { + if processArgsRequestHelp(c) { + return cli.ShowSubcommandHelp(c) + } client, id, processID, err := processClientSandboxAndProcess(c) if err != nil { return err @@ -616,13 +670,14 @@ func processClientSandboxAndOptionalProcess(c *cli.Context) (*api.SandboxClient, } func processCreateRequestFromCLI(c *cli.Context, shellMode bool) (api.ProcessCreateRequest, error) { + subcommand := c.Command.Name envs, err := parseEnvFlags(c.StringSlice("env")) if err != nil { return api.ProcessCreateRequest{}, err } ref, cmd, args := parseProcessCommandArgs(c) _ = ref - if !shellMode && cmd == "" && !c.Bool("pty") { + if !shellMode && cmd == "" && !processBoolFlag(c, subcommand, "pty") { return api.ProcessCreateRequest{}, fmt.Errorf("please pass the command after '--'\n\n Example:\n createos sandbox process run my-box -- npm test") } req := api.ProcessCreateRequest{ @@ -631,7 +686,7 @@ func processCreateRequestFromCLI(c *cli.Context, shellMode bool) (api.ProcessCre Cwd: strings.TrimSpace(c.String("cwd")), Env: envs, } - if c.Bool("pty") { + if processBoolFlag(c, subcommand, "pty") { req.PTY = ptyOptionsFromFlags(c, false) } return req, nil @@ -744,7 +799,7 @@ func processRowsCols(c *cli.Context) (int, int, error) { return rows, cols, nil } -func pickRunningPTY(c *cli.Context, client *api.SandboxClient, sandboxID, title string) (string, error) { +func pickAttachableProcess(c *cli.Context, client *api.SandboxClient, sandboxID, title string) (string, error) { if !terminal.IsInteractive() { return "", fmt.Errorf("please provide a process ID\n\n Example:\n createos sandbox process attach %s ", sandboxID) } @@ -754,10 +809,10 @@ func pickRunningPTY(c *cli.Context, client *api.SandboxClient, sandboxID, title } items := make([]ui.PickerItem, 0, len(processes)) for _, proc := range processes { - if proc.Kind != "pty" || proc.LeaderExited || proc.TreeExited || proc.State != "running" { + if !processCanBePickedForAttach(proc) { continue } - subtitle := fmt.Sprintf("pid: %d, command: %s, created: %s, output: %d bytes", proc.PID, processCommandLabel(proc, 32), processLocalClock(proc.CreatedAt), proc.Output.Bytes) + subtitle := fmt.Sprintf("kind: %s, state: %s, pid: %d, command: %s, created: %s, output: %d bytes", processKindLabel(proc), processStateLabel(proc), proc.PID, processCommandLabel(proc, 32), processLocalClock(proc.CreatedAt), proc.Output.Bytes) items = append(items, ui.PickerItem{ Title: proc.ProcessID, Subtitle: subtitle, @@ -765,18 +820,22 @@ func pickRunningPTY(c *cli.Context, client *api.SandboxClient, sandboxID, title }) } if len(items) == 0 { - return "", errNoRunningPTY + return "", errNoRunningProcess } return ui.Pick(title, items) } -var errNoRunningPTY = errors.New("there are no running shell sessions in this sandbox") +func processCanBePickedForAttach(proc api.ProcessDetails) bool { + return proc.State == "running" && !proc.LeaderExited && !proc.TreeExited +} + +var errNoRunningProcess = errors.New("there are no running managed processes in this sandbox") func promptCreatePTYAndAttach(c *cli.Context, client *api.SandboxClient, sandboxID string) (*api.ProcessDetails, error) { if !terminal.IsInteractive() { - return nil, fmt.Errorf("%w\n\n Start one:\n createos sandbox process shell %s", errNoRunningPTY, sandboxID) + return nil, fmt.Errorf("%w\n\n Start one:\n createos sandbox process shell %s", errNoRunningProcess, sandboxID) } - pterm.Warning.Println("There are no running shell sessions in this sandbox.") + pterm.Warning.Println("There are no running managed processes in this sandbox.") ok, err := pterm.DefaultInteractiveConfirm. WithDefaultText("Create a new shell session and attach?"). WithDefaultValue(true). @@ -800,7 +859,7 @@ func attachProcess(c *cli.Context, client *api.SandboxClient, sandboxID, ref str if retryAfter, ok := processAttachRetryAfter(nextID); ok { offsetRetries++ if offsetRetries > 1 { - return nil, "", fmt.Errorf("some previous output is no longer available; try attaching again without --after, or run 'createos sandbox process get %s %s' to see the retained output range", refLabel(ref, sandboxID), proc.ProcessID) + return nil, "", fmt.Errorf("some previous output is no longer available; try attaching again without --after, or run 'createos sandbox process get %s %s' to inspect this process", refLabel(ref, sandboxID), proc.ProcessID) } nextProc, getErr := client.GetProcess(c.Context, sandboxID, proc.ProcessID) if getErr != nil { @@ -819,7 +878,7 @@ func attachProcess(c *cli.Context, client *api.SandboxClient, sandboxID, ref str } if nextID == processAttachPickSentinel { prepareTerminalForProcessPicker() - pickedID, pickErr := pickRunningPTY(c, client, sandboxID, "Attach to which shell session?") + pickedID, pickErr := pickAttachableProcess(c, client, sandboxID, "Attach to which process?") if pickErr != nil { return nil, "", pickErr } @@ -839,9 +898,9 @@ func attachProcess(c *cli.Context, client *api.SandboxClient, sandboxID, ref str } } -func processInitialAttachAfter(c *cli.Context, proc *api.ProcessDetails, noFollow bool) int64 { - after := c.Int64("after") - if noFollow || c.IsSet("after") || proc == nil { +func processInitialAttachAfter(c *cli.Context, subcommand string, proc *api.ProcessDetails, noFollow bool) int64 { + after := processInt64Flag(c, subcommand, "after") + if noFollow || processFlagIsSet(c, subcommand, "after") || proc == nil { return after } return proc.Output.NewestSeq @@ -1026,6 +1085,9 @@ func copyProcessInput(ctx context.Context, detach context.CancelFunc, nextCh cha _, _ = client.WriteProcessInput(ctx, sandboxID, processID, api.ProcessInputRequest{DataBase64: encoded}) //nolint:errcheck } if err != nil { + if !pty && errors.Is(err, io.EOF) { + _ = client.CloseProcessStdin(ctx, sandboxID, processID) //nolint:errcheck + } return } } @@ -1333,6 +1395,16 @@ func printProcessCreated(proc *api.ProcessDetails) { pterm.Success.Printf("Started %s (%s).\n", proc.ProcessID, proc.Kind) } +func printProcessAttachExit(exitCode *int, signal string) { + if exitCode != nil { + pterm.Fprintln(os.Stderr, pterm.Gray(fmt.Sprintf("Process exited with code %d.", *exitCode))) + return + } + if signal != "" { + pterm.Fprintln(os.Stderr, pterm.Gray(fmt.Sprintf("Process exited from signal %s.", signal))) + } +} + func printProcessDetails(proc *api.ProcessDetails) { if proc == nil { return @@ -1379,6 +1451,13 @@ func processLocalRFC3339(t time.Time) string { return t.In(time.Local).Format(time.RFC3339) } +func processKindLabel(proc api.ProcessDetails) string { + if proc.Kind == "pty" { + return "shell" + } + return "process" +} + func processCommandLabel(proc api.ProcessDetails, maxLen int) string { if proc.Foreground != nil && strings.TrimSpace(proc.Foreground.Cmd) != "" { return truncateProcessLabel(strings.TrimSpace(proc.Foreground.Cmd), maxLen) @@ -1481,6 +1560,21 @@ func processIntFlag(c *cli.Context, subcommand, name string) int { return v } +func processInt64Flag(c *cli.Context, subcommand, name string) int64 { + if v := c.Int64(name); v != 0 { + return v + } + raw := rawProcessFlagValue(subcommand, name) + if raw == "" { + return 0 + } + v, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return 0 + } + return v +} + func processDurationFlag(c *cli.Context, subcommand, name string) time.Duration { if v := c.Duration(name); v != 0 { return v @@ -1496,6 +1590,10 @@ func processDurationFlag(c *cli.Context, subcommand, name string) time.Duration return v } +func processFlagIsSet(c *cli.Context, subcommand, name string) bool { + return c.IsSet(name) || rawProcessFlagPresent(subcommand, name) +} + func rawProcessFlagPresent(subcommand, name string) bool { target := "--" + name for i := processSubcommandArgIndex(subcommand) + 1; i > 0 && i < len(os.Args); i++ { diff --git a/cmd/skills/catalog.go b/cmd/skills/catalog.go deleted file mode 100644 index 2acb97b..0000000 --- a/cmd/skills/catalog.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package skills provides skills management commands. -package skills - -import ( - "fmt" - - "github.com/urfave/cli/v2" - - "github.com/NodeOps-app/createos-cli/internal/api" - "github.com/NodeOps-app/createos-cli/internal/ui" -) - -func newCatalog() *cli.Command { - return &cli.Command{ - Name: "catalog", - Usage: "Browse and purchase skills from the catalog", - Action: func(c *cli.Context) error { - client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) - if !ok { - return fmt.Errorf("you're not signed in — run 'createos login' to get started") - } - - const pageSize = 20 - skills, pagination, err := client.ListAvailableSkillsForPurchase("", 0, pageSize) - if err != nil { - return err - } - - purchasedIDs := make(map[string]string) - if purchased, err := client.ListMyPurchasedSkills(); err == nil { - for _, item := range purchased { - purchasedIDs[item.SkillID] = item.ID - } - } - - return ui.RunCatalogList(skills, pagination, 0, "", purchasedIDs, client) - }, - } -} diff --git a/cmd/skills/purchased.go b/cmd/skills/purchased.go deleted file mode 100644 index 19815a8..0000000 --- a/cmd/skills/purchased.go +++ /dev/null @@ -1,35 +0,0 @@ -package skills - -import ( - "fmt" - - "github.com/urfave/cli/v2" - - "github.com/NodeOps-app/createos-cli/internal/api" - "github.com/NodeOps-app/createos-cli/internal/ui" -) - -func newPurchasedCommand() *cli.Command { - return &cli.Command{ - Name: "purchased", - Usage: "List all purchased skills", - Action: func(c *cli.Context) error { - client, ok := c.App.Metadata[api.ClientKey].(*api.APIClient) - if !ok { - return fmt.Errorf("you're not signed in — run 'createos login' to get started") - } - - items, err := client.ListMyPurchasedSkills() - if err != nil { - return err - } - - if len(items) == 0 { - fmt.Println("You haven't purchased any skills yet. Browse the catalog with 'createos skills catalog'.") - return nil - } - - return ui.RunSkillsList(items, client) - }, - } -} diff --git a/cmd/skills/skills.go b/cmd/skills/skills.go deleted file mode 100644 index c8ba1c6..0000000 --- a/cmd/skills/skills.go +++ /dev/null @@ -1,17 +0,0 @@ -package skills - -import ( - "github.com/urfave/cli/v2" -) - -// NewSkillsCommand creates the skills command with subcommands -func NewSkillsCommand() *cli.Command { - return &cli.Command{ - Name: "skills", - Usage: "Manage skills", - Subcommands: []*cli.Command{ - newPurchasedCommand(), - newCatalog(), - }, - } -}