From 32df8c39eed404ce8e599ed9602b8b2a99f1f28f Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 13 Apr 2026 19:00:47 -0400 Subject: [PATCH 1/3] feat: add -i/--interactive stdin support to vers exec - Read stdin and pass it via ExecRequest.Stdin field to orchestrator API - Wire stdin through SSH path using StartSession for pipe-based execution - Fix arg parsing: detect VM target by UUID/alias instead of assuming first arg is always a target - Add LooksLikeVMTarget helper (UUID regex + alias lookup) --- cmd/execute.go | 33 ++++++++++++++++++++++++----- internal/handlers/execute.go | 40 ++++++++++++++++++++++++++++++++++++ internal/utils/vm_target.go | 24 ++++++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 internal/utils/vm_target.go diff --git a/cmd/execute.go b/cmd/execute.go index 22f63c3..e2a045c 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -2,17 +2,21 @@ package cmd import ( "context" + "fmt" + "io" "os" "time" "github.com/hdresearch/vers-cli/internal/handlers" pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/hdresearch/vers-cli/internal/utils" "github.com/spf13/cobra" ) var executeTimeout int var executeSSH bool var executeWorkDir string +var executeStdin bool // executeCmd represents the execute command var executeCmd = &cobra.Command{ @@ -26,6 +30,11 @@ inherits environment variables configured for your account. If no VM is specified, the current HEAD VM is used. +Use -i to pass stdin from the local terminal to the remote command. +This is useful for piping data into commands, e.g.: + + echo '{"jsonrpc":"2.0","method":"ping","id":1}' | vers exec -i my-server + Use --ssh to bypass the API and connect directly via SSH (legacy behavior).`, Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -39,19 +48,21 @@ Use --ssh to bypass the API and connect directly via SSH (legacy behavior).`, // Determine if the first arg is a VM target or part of the command. // If there's only one arg, it's the command (use HEAD VM). - // If there are multiple args, try to resolve the first arg as a VM; - // if it resolves, treat it as the target, otherwise treat all args as the command. + // If there are multiple args, check if the first arg looks like a VM + // identifier (UUID or known alias). If so, treat it as the target; + // otherwise treat all args as the command and use HEAD. var target string var command []string if len(args) == 1 { - // Only a command, use HEAD VM target = "" command = args - } else { - // First arg is the target VM, remaining args are the command + } else if utils.LooksLikeVMTarget(args[0]) { target = args[0] command = args[1:] + } else { + target = "" + command = args } var timeoutSec uint64 @@ -59,12 +70,23 @@ Use --ssh to bypass the API and connect directly via SSH (legacy behavior).`, timeoutSec = uint64(executeTimeout) } + // Read stdin if -i flag is set + var stdinData string + if executeStdin { + data, err := io.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read stdin: %w", err) + } + stdinData = string(data) + } + view, err := handlers.HandleExecute(apiCtx, application, handlers.ExecuteReq{ Target: target, Command: command, WorkingDir: executeWorkDir, TimeoutSec: timeoutSec, UseSSH: executeSSH, + Stdin: stdinData, }) if err != nil { return err @@ -85,4 +107,5 @@ func init() { executeCmd.Flags().IntVarP(&executeTimeout, "timeout", "t", 0, "Timeout in seconds (default: 30s, use 0 for no limit)") executeCmd.Flags().BoolVar(&executeSSH, "ssh", false, "Use direct SSH instead of the VERS API") executeCmd.Flags().StringVarP(&executeWorkDir, "workdir", "w", "", "Working directory for the command") + executeCmd.Flags().BoolVarP(&executeStdin, "interactive", "i", false, "Pass stdin to the remote command") } diff --git a/internal/handlers/execute.go b/internal/handlers/execute.go index 2e9a684..d0f8096 100644 --- a/internal/handlers/execute.go +++ b/internal/handlers/execute.go @@ -23,6 +23,7 @@ type ExecuteReq struct { Env map[string]string TimeoutSec uint64 UseSSH bool + Stdin string } // streamResponse represents a single NDJSON line from the exec stream. @@ -67,6 +68,7 @@ func handleExecuteAPI(ctx context.Context, a *app.App, r ExecuteReq, t utils.Tar Command: command, Env: r.Env, WorkingDir: r.WorkingDir, + Stdin: r.Stdin, TimeoutSec: r.TimeoutSec, }) if err != nil { @@ -92,6 +94,11 @@ func handleExecuteSSH(ctx context.Context, a *app.App, r ExecuteReq, t utils.Tar cmdStr := utils.ShellJoin(r.Command) client := sshutil.NewClient(info.Host, info.KeyPath, info.VMDomain) + + if r.Stdin != "" { + return handleExecuteSSHWithStdin(ctx, client, cmdStr, r.Stdin, a, v) + } + err = client.Execute(ctx, cmdStr, a.IO.Out, a.IO.Err) if err != nil { if exitErr, ok := err.(*ssh.ExitError); ok { @@ -103,6 +110,39 @@ func handleExecuteSSH(ctx context.Context, a *app.App, r ExecuteReq, t utils.Tar return v, nil } +// handleExecuteSSHWithStdin runs a command via SSH, piping stdin data to the remote process. +func handleExecuteSSHWithStdin(ctx context.Context, client *sshutil.Client, cmd, stdinData string, a *app.App, v presenters.ExecuteView) (presenters.ExecuteView, error) { + sess, err := client.StartSession(ctx) + if err != nil { + return v, fmt.Errorf("failed to start SSH session: %w", err) + } + defer sess.Close() + + // Copy stdout/stderr in background + go io.Copy(a.IO.Out, sess.Stdout()) + go io.Copy(a.IO.Err, sess.Stderr()) + + if err := sess.Start(cmd); err != nil { + return v, fmt.Errorf("failed to start command: %w", err) + } + + // Write stdin data and close to signal EOF + if _, err := io.WriteString(sess.Stdin(), stdinData); err != nil { + return v, fmt.Errorf("failed to write stdin: %w", err) + } + sess.Stdin().Close() + + err = sess.Wait() + if err != nil { + if exitErr, ok := err.(*ssh.ExitError); ok { + v.ExitCode = exitErr.ExitStatus() + return v, nil + } + return v, fmt.Errorf("command failed: %w", err) + } + return v, nil +} + // streamExecOutput reads NDJSON from the exec stream, writes stdout/stderr // to the provided writers, and returns the exit code. func streamExecOutput(body io.Reader, stdout, stderr io.Writer) (int, error) { diff --git a/internal/utils/vm_target.go b/internal/utils/vm_target.go new file mode 100644 index 0000000..d1156ef --- /dev/null +++ b/internal/utils/vm_target.go @@ -0,0 +1,24 @@ +package utils + +import ( + "regexp" +) + +// uuidRegex matches a standard UUID v4 format. +var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// LooksLikeVMTarget returns true if the string looks like a VM identifier +// (a UUID or a known alias), as opposed to a shell command. +func LooksLikeVMTarget(s string) bool { + if uuidRegex.MatchString(s) { + return true + } + + // Check if it's a known alias + resolved := ResolveAlias(s) + if resolved != s { + return true + } + + return false +} From 01d801ba9efe0662b92605f556441754ea72bf28 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 13 Apr 2026 19:02:17 -0400 Subject: [PATCH 2/3] test: add tests for LooksLikeVMTarget and updated exec arg parsing - Unit tests for UUID detection (valid, invalid, edge cases) - Unit tests for alias-based VM target detection - Updated existing execute arg parsing tests to use new LooksLikeVMTarget logic - Fixed test cobra command to use SetInterspersed(false) matching real command --- cmd/execute_test.go | 52 +++++++++++++++++++----- internal/utils/vm_target_test.go | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 internal/utils/vm_target_test.go diff --git a/cmd/execute_test.go b/cmd/execute_test.go index f75f89c..754d08b 100644 --- a/cmd/execute_test.go +++ b/cmd/execute_test.go @@ -8,7 +8,11 @@ import ( "github.com/spf13/cobra" ) -// TestExecuteCommandArgumentParsing tests the argument parsing logic for execute +// TestExecuteCommandArgumentParsing tests the argument parsing logic for execute. +// The logic uses LooksLikeVMTarget to decide if the first arg is a VM or a command: +// - UUID → treated as VM target +// - Known alias → treated as VM target +// - Anything else → treated as command, uses HEAD func TestExecuteCommandArgumentParsing(t *testing.T) { tests := []struct { name string @@ -19,26 +23,47 @@ func TestExecuteCommandArgumentParsing(t *testing.T) { errorMessage string }{ { - name: "2+ args: first is target, rest is command", - args: []string{"my-vm", "echo", "hello"}, + name: "UUID target + command args", + args: []string{"3bfea344-6bf2-4655-be27-64be7b5eb332", "echo", "hello"}, expectError: false, - expectedTarget: "my-vm", + expectedTarget: "3bfea344-6bf2-4655-be27-64be7b5eb332", expectedCommand: []string{"echo", "hello"}, }, { - name: "2 args: target and single command", - args: []string{"my-vm", "ls"}, + name: "UUID target + single command", + args: []string{"3bfea344-6bf2-4655-be27-64be7b5eb332", "ls"}, expectError: false, - expectedTarget: "my-vm", + expectedTarget: "3bfea344-6bf2-4655-be27-64be7b5eb332", expectedCommand: []string{"ls"}, }, { - name: "1 arg: command only, uses HEAD", + name: "command only (not a UUID), uses HEAD", + args: []string{"echo", "hello"}, + expectError: false, + expectedTarget: "", + expectedCommand: []string{"echo", "hello"}, + }, + { + name: "single command, uses HEAD", args: []string{"echo hello"}, expectError: false, expectedTarget: "", expectedCommand: []string{"echo hello"}, }, + { + name: "command with path, not a UUID", + args: []string{"jq", ".foo"}, + expectError: false, + expectedTarget: "", + expectedCommand: []string{"jq", ".foo"}, + }, + { + name: "python command, not a UUID", + args: []string{"python3", "-c", "print('hi')"}, + expectError: false, + expectedTarget: "", + expectedCommand: []string{"python3", "-c", "print('hi')"}, + }, { name: "0 args: error", args: []string{}, @@ -59,14 +84,18 @@ func TestExecuteCommandArgumentParsing(t *testing.T) { if len(args) == 1 { capturedTarget = "" capturedCommand = args - } else { + } else if utils.LooksLikeVMTarget(args[0]) { capturedTarget = args[0] capturedCommand = args[1:] + } else { + capturedTarget = "" + capturedCommand = args } return nil }, } + cmd.Flags().SetInterspersed(false) cmd.SetArgs(tt.args) err := cmd.Execute() @@ -139,9 +168,12 @@ func TestExecuteCommandWithHEAD(t *testing.T) { if len(args) == 1 { target = "" command = args - } else { + } else if utils.LooksLikeVMTarget(args[0]) { target = args[0] command = args[1:] + } else { + target = "" + command = args } // When target is empty, HEAD should be used diff --git a/internal/utils/vm_target_test.go b/internal/utils/vm_target_test.go new file mode 100644 index 0000000..194e137 --- /dev/null +++ b/internal/utils/vm_target_test.go @@ -0,0 +1,69 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLooksLikeVMTarget_UUID(t *testing.T) { + tests := []struct { + name string + input string + expect bool + }{ + {"valid UUID", "3bfea344-6bf2-4655-be27-64be7b5eb332", true}, + {"valid UUID uppercase", "3BFEA344-6BF2-4655-BE27-64BE7B5EB332", true}, + {"valid UUID mixed case", "3bFeA344-6Bf2-4655-bE27-64bE7b5eB332", true}, + {"zeroed UUID", "00000000-0000-0000-0000-000000000000", true}, + {"not a UUID - command", "echo", false}, + {"not a UUID - command with path", "/usr/bin/cat", false}, + {"not a UUID - partial UUID", "3bfea344-6bf2", false}, + {"not a UUID - jq", "jq", false}, + {"not a UUID - python3", "python3", false}, + {"not a UUID - ls", "ls", false}, + {"not a UUID - bash", "bash", false}, + {"not a UUID - empty", "", false}, + {"not a UUID - UUID without dashes", "3bfea3446bf24655be2764be7b5eb332", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := LooksLikeVMTarget(tt.input) + if got != tt.expect { + t.Errorf("LooksLikeVMTarget(%q) = %v, want %v", tt.input, got, tt.expect) + } + }) + } +} + +func TestLooksLikeVMTarget_Alias(t *testing.T) { + // Set up a temp home dir with aliases + origHome := os.Getenv("HOME") + tmpHome := t.TempDir() + os.Setenv("HOME", tmpHome) + defer os.Setenv("HOME", origHome) + + aliasDir := filepath.Join(tmpHome, ".vers") + os.MkdirAll(aliasDir, 0755) + os.WriteFile(filepath.Join(aliasDir, "aliases.json"), []byte(`{"my-dev-vm":"3bfea344-6bf2-4655-be27-64be7b5eb332"}`), 0644) + + tests := []struct { + name string + input string + expect bool + }{ + {"known alias", "my-dev-vm", true}, + {"unknown alias", "no-such-alias", false}, + {"command not alias", "cat", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := LooksLikeVMTarget(tt.input) + if got != tt.expect { + t.Errorf("LooksLikeVMTarget(%q) = %v, want %v", tt.input, got, tt.expect) + } + }) + } +} From 1e9ddda837c7761b45a3c5c17c362adbb7554c5d Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 13 Apr 2026 19:13:31 -0400 Subject: [PATCH 3/3] fix: set USERPROFILE in alias test for Windows compatibility --- internal/utils/vm_target_test.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/utils/vm_target_test.go b/internal/utils/vm_target_test.go index 194e137..eb8ba9f 100644 --- a/internal/utils/vm_target_test.go +++ b/internal/utils/vm_target_test.go @@ -38,11 +38,17 @@ func TestLooksLikeVMTarget_UUID(t *testing.T) { } func TestLooksLikeVMTarget_Alias(t *testing.T) { - // Set up a temp home dir with aliases - origHome := os.Getenv("HOME") + // Set up a temp home dir with aliases. + // Must set both HOME (Unix) and USERPROFILE (Windows) since + // os.UserHomeDir() checks USERPROFILE on Windows. tmpHome := t.TempDir() + + origHome := os.Getenv("HOME") + origUserProfile := os.Getenv("USERPROFILE") os.Setenv("HOME", tmpHome) + os.Setenv("USERPROFILE", tmpHome) defer os.Setenv("HOME", origHome) + defer os.Setenv("USERPROFILE", origUserProfile) aliasDir := filepath.Join(tmpHome, ".vers") os.MkdirAll(aliasDir, 0755)