Skip to content

Commit 9f212f2

Browse files
Merge pull request #184 from hdresearch/feat/exec-stdin
feat: add -i/--interactive stdin support to vers exec
2 parents c3f1b60 + 1e9ddda commit 9f212f2

5 files changed

Lines changed: 209 additions & 15 deletions

File tree

cmd/execute.go

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,21 @@ package cmd
22

33
import (
44
"context"
5+
"fmt"
6+
"io"
57
"os"
68
"time"
79

810
"github.com/hdresearch/vers-cli/internal/handlers"
911
pres "github.com/hdresearch/vers-cli/internal/presenters"
12+
"github.com/hdresearch/vers-cli/internal/utils"
1013
"github.com/spf13/cobra"
1114
)
1215

1316
var executeTimeout int
1417
var executeSSH bool
1518
var executeWorkDir string
19+
var executeStdin bool
1620

1721
// executeCmd represents the execute command
1822
var executeCmd = &cobra.Command{
@@ -26,6 +30,11 @@ inherits environment variables configured for your account.
2630
2731
If no VM is specified, the current HEAD VM is used.
2832
33+
Use -i to pass stdin from the local terminal to the remote command.
34+
This is useful for piping data into commands, e.g.:
35+
36+
echo '{"jsonrpc":"2.0","method":"ping","id":1}' | vers exec -i <vm> my-server
37+
2938
Use --ssh to bypass the API and connect directly via SSH (legacy behavior).`,
3039
Args: cobra.MinimumNArgs(1),
3140
RunE: func(cmd *cobra.Command, args []string) error {
@@ -39,32 +48,45 @@ Use --ssh to bypass the API and connect directly via SSH (legacy behavior).`,
3948

4049
// Determine if the first arg is a VM target or part of the command.
4150
// If there's only one arg, it's the command (use HEAD VM).
42-
// If there are multiple args, try to resolve the first arg as a VM;
43-
// if it resolves, treat it as the target, otherwise treat all args as the command.
51+
// If there are multiple args, check if the first arg looks like a VM
52+
// identifier (UUID or known alias). If so, treat it as the target;
53+
// otherwise treat all args as the command and use HEAD.
4454
var target string
4555
var command []string
4656

4757
if len(args) == 1 {
48-
// Only a command, use HEAD VM
4958
target = ""
5059
command = args
51-
} else {
52-
// First arg is the target VM, remaining args are the command
60+
} else if utils.LooksLikeVMTarget(args[0]) {
5361
target = args[0]
5462
command = args[1:]
63+
} else {
64+
target = ""
65+
command = args
5566
}
5667

5768
var timeoutSec uint64
5869
if executeTimeout > 0 {
5970
timeoutSec = uint64(executeTimeout)
6071
}
6172

73+
// Read stdin if -i flag is set
74+
var stdinData string
75+
if executeStdin {
76+
data, err := io.ReadAll(os.Stdin)
77+
if err != nil {
78+
return fmt.Errorf("failed to read stdin: %w", err)
79+
}
80+
stdinData = string(data)
81+
}
82+
6283
view, err := handlers.HandleExecute(apiCtx, application, handlers.ExecuteReq{
6384
Target: target,
6485
Command: command,
6586
WorkingDir: executeWorkDir,
6687
TimeoutSec: timeoutSec,
6788
UseSSH: executeSSH,
89+
Stdin: stdinData,
6890
})
6991
if err != nil {
7092
return err
@@ -85,4 +107,5 @@ func init() {
85107
executeCmd.Flags().IntVarP(&executeTimeout, "timeout", "t", 0, "Timeout in seconds (default: 30s, use 0 for no limit)")
86108
executeCmd.Flags().BoolVar(&executeSSH, "ssh", false, "Use direct SSH instead of the VERS API")
87109
executeCmd.Flags().StringVarP(&executeWorkDir, "workdir", "w", "", "Working directory for the command")
110+
executeCmd.Flags().BoolVarP(&executeStdin, "interactive", "i", false, "Pass stdin to the remote command")
88111
}

cmd/execute_test.go

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import (
88
"github.com/spf13/cobra"
99
)
1010

11-
// TestExecuteCommandArgumentParsing tests the argument parsing logic for execute
11+
// TestExecuteCommandArgumentParsing tests the argument parsing logic for execute.
12+
// The logic uses LooksLikeVMTarget to decide if the first arg is a VM or a command:
13+
// - UUID → treated as VM target
14+
// - Known alias → treated as VM target
15+
// - Anything else → treated as command, uses HEAD
1216
func TestExecuteCommandArgumentParsing(t *testing.T) {
1317
tests := []struct {
1418
name string
@@ -19,26 +23,47 @@ func TestExecuteCommandArgumentParsing(t *testing.T) {
1923
errorMessage string
2024
}{
2125
{
22-
name: "2+ args: first is target, rest is command",
23-
args: []string{"my-vm", "echo", "hello"},
26+
name: "UUID target + command args",
27+
args: []string{"3bfea344-6bf2-4655-be27-64be7b5eb332", "echo", "hello"},
2428
expectError: false,
25-
expectedTarget: "my-vm",
29+
expectedTarget: "3bfea344-6bf2-4655-be27-64be7b5eb332",
2630
expectedCommand: []string{"echo", "hello"},
2731
},
2832
{
29-
name: "2 args: target and single command",
30-
args: []string{"my-vm", "ls"},
33+
name: "UUID target + single command",
34+
args: []string{"3bfea344-6bf2-4655-be27-64be7b5eb332", "ls"},
3135
expectError: false,
32-
expectedTarget: "my-vm",
36+
expectedTarget: "3bfea344-6bf2-4655-be27-64be7b5eb332",
3337
expectedCommand: []string{"ls"},
3438
},
3539
{
36-
name: "1 arg: command only, uses HEAD",
40+
name: "command only (not a UUID), uses HEAD",
41+
args: []string{"echo", "hello"},
42+
expectError: false,
43+
expectedTarget: "",
44+
expectedCommand: []string{"echo", "hello"},
45+
},
46+
{
47+
name: "single command, uses HEAD",
3748
args: []string{"echo hello"},
3849
expectError: false,
3950
expectedTarget: "",
4051
expectedCommand: []string{"echo hello"},
4152
},
53+
{
54+
name: "command with path, not a UUID",
55+
args: []string{"jq", ".foo"},
56+
expectError: false,
57+
expectedTarget: "",
58+
expectedCommand: []string{"jq", ".foo"},
59+
},
60+
{
61+
name: "python command, not a UUID",
62+
args: []string{"python3", "-c", "print('hi')"},
63+
expectError: false,
64+
expectedTarget: "",
65+
expectedCommand: []string{"python3", "-c", "print('hi')"},
66+
},
4267
{
4368
name: "0 args: error",
4469
args: []string{},
@@ -59,14 +84,18 @@ func TestExecuteCommandArgumentParsing(t *testing.T) {
5984
if len(args) == 1 {
6085
capturedTarget = ""
6186
capturedCommand = args
62-
} else {
87+
} else if utils.LooksLikeVMTarget(args[0]) {
6388
capturedTarget = args[0]
6489
capturedCommand = args[1:]
90+
} else {
91+
capturedTarget = ""
92+
capturedCommand = args
6593
}
6694
return nil
6795
},
6896
}
6997

98+
cmd.Flags().SetInterspersed(false)
7099
cmd.SetArgs(tt.args)
71100
err := cmd.Execute()
72101

@@ -139,9 +168,12 @@ func TestExecuteCommandWithHEAD(t *testing.T) {
139168
if len(args) == 1 {
140169
target = ""
141170
command = args
142-
} else {
171+
} else if utils.LooksLikeVMTarget(args[0]) {
143172
target = args[0]
144173
command = args[1:]
174+
} else {
175+
target = ""
176+
command = args
145177
}
146178

147179
// When target is empty, HEAD should be used

internal/handlers/execute.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type ExecuteReq struct {
2323
Env map[string]string
2424
TimeoutSec uint64
2525
UseSSH bool
26+
Stdin string
2627
}
2728

2829
// 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
6768
Command: command,
6869
Env: r.Env,
6970
WorkingDir: r.WorkingDir,
71+
Stdin: r.Stdin,
7072
TimeoutSec: r.TimeoutSec,
7173
})
7274
if err != nil {
@@ -92,6 +94,11 @@ func handleExecuteSSH(ctx context.Context, a *app.App, r ExecuteReq, t utils.Tar
9294

9395
cmdStr := utils.ShellJoin(r.Command)
9496
client := sshutil.NewClient(info.Host, info.KeyPath, info.VMDomain)
97+
98+
if r.Stdin != "" {
99+
return handleExecuteSSHWithStdin(ctx, client, cmdStr, r.Stdin, a, v)
100+
}
101+
95102
err = client.Execute(ctx, cmdStr, a.IO.Out, a.IO.Err)
96103
if err != nil {
97104
if exitErr, ok := err.(*ssh.ExitError); ok {
@@ -103,6 +110,39 @@ func handleExecuteSSH(ctx context.Context, a *app.App, r ExecuteReq, t utils.Tar
103110
return v, nil
104111
}
105112

113+
// handleExecuteSSHWithStdin runs a command via SSH, piping stdin data to the remote process.
114+
func handleExecuteSSHWithStdin(ctx context.Context, client *sshutil.Client, cmd, stdinData string, a *app.App, v presenters.ExecuteView) (presenters.ExecuteView, error) {
115+
sess, err := client.StartSession(ctx)
116+
if err != nil {
117+
return v, fmt.Errorf("failed to start SSH session: %w", err)
118+
}
119+
defer sess.Close()
120+
121+
// Copy stdout/stderr in background
122+
go io.Copy(a.IO.Out, sess.Stdout())
123+
go io.Copy(a.IO.Err, sess.Stderr())
124+
125+
if err := sess.Start(cmd); err != nil {
126+
return v, fmt.Errorf("failed to start command: %w", err)
127+
}
128+
129+
// Write stdin data and close to signal EOF
130+
if _, err := io.WriteString(sess.Stdin(), stdinData); err != nil {
131+
return v, fmt.Errorf("failed to write stdin: %w", err)
132+
}
133+
sess.Stdin().Close()
134+
135+
err = sess.Wait()
136+
if err != nil {
137+
if exitErr, ok := err.(*ssh.ExitError); ok {
138+
v.ExitCode = exitErr.ExitStatus()
139+
return v, nil
140+
}
141+
return v, fmt.Errorf("command failed: %w", err)
142+
}
143+
return v, nil
144+
}
145+
106146
// streamExecOutput reads NDJSON from the exec stream, writes stdout/stderr
107147
// to the provided writers, and returns the exit code.
108148
func streamExecOutput(body io.Reader, stdout, stderr io.Writer) (int, error) {

internal/utils/vm_target.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package utils
2+
3+
import (
4+
"regexp"
5+
)
6+
7+
// uuidRegex matches a standard UUID v4 format.
8+
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}$`)
9+
10+
// LooksLikeVMTarget returns true if the string looks like a VM identifier
11+
// (a UUID or a known alias), as opposed to a shell command.
12+
func LooksLikeVMTarget(s string) bool {
13+
if uuidRegex.MatchString(s) {
14+
return true
15+
}
16+
17+
// Check if it's a known alias
18+
resolved := ResolveAlias(s)
19+
if resolved != s {
20+
return true
21+
}
22+
23+
return false
24+
}

internal/utils/vm_target_test.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package utils
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestLooksLikeVMTarget_UUID(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
input string
13+
expect bool
14+
}{
15+
{"valid UUID", "3bfea344-6bf2-4655-be27-64be7b5eb332", true},
16+
{"valid UUID uppercase", "3BFEA344-6BF2-4655-BE27-64BE7B5EB332", true},
17+
{"valid UUID mixed case", "3bFeA344-6Bf2-4655-bE27-64bE7b5eB332", true},
18+
{"zeroed UUID", "00000000-0000-0000-0000-000000000000", true},
19+
{"not a UUID - command", "echo", false},
20+
{"not a UUID - command with path", "/usr/bin/cat", false},
21+
{"not a UUID - partial UUID", "3bfea344-6bf2", false},
22+
{"not a UUID - jq", "jq", false},
23+
{"not a UUID - python3", "python3", false},
24+
{"not a UUID - ls", "ls", false},
25+
{"not a UUID - bash", "bash", false},
26+
{"not a UUID - empty", "", false},
27+
{"not a UUID - UUID without dashes", "3bfea3446bf24655be2764be7b5eb332", false},
28+
}
29+
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
got := LooksLikeVMTarget(tt.input)
33+
if got != tt.expect {
34+
t.Errorf("LooksLikeVMTarget(%q) = %v, want %v", tt.input, got, tt.expect)
35+
}
36+
})
37+
}
38+
}
39+
40+
func TestLooksLikeVMTarget_Alias(t *testing.T) {
41+
// Set up a temp home dir with aliases.
42+
// Must set both HOME (Unix) and USERPROFILE (Windows) since
43+
// os.UserHomeDir() checks USERPROFILE on Windows.
44+
tmpHome := t.TempDir()
45+
46+
origHome := os.Getenv("HOME")
47+
origUserProfile := os.Getenv("USERPROFILE")
48+
os.Setenv("HOME", tmpHome)
49+
os.Setenv("USERPROFILE", tmpHome)
50+
defer os.Setenv("HOME", origHome)
51+
defer os.Setenv("USERPROFILE", origUserProfile)
52+
53+
aliasDir := filepath.Join(tmpHome, ".vers")
54+
os.MkdirAll(aliasDir, 0755)
55+
os.WriteFile(filepath.Join(aliasDir, "aliases.json"), []byte(`{"my-dev-vm":"3bfea344-6bf2-4655-be27-64be7b5eb332"}`), 0644)
56+
57+
tests := []struct {
58+
name string
59+
input string
60+
expect bool
61+
}{
62+
{"known alias", "my-dev-vm", true},
63+
{"unknown alias", "no-such-alias", false},
64+
{"command not alias", "cat", false},
65+
}
66+
67+
for _, tt := range tests {
68+
t.Run(tt.name, func(t *testing.T) {
69+
got := LooksLikeVMTarget(tt.input)
70+
if got != tt.expect {
71+
t.Errorf("LooksLikeVMTarget(%q) = %v, want %v", tt.input, got, tt.expect)
72+
}
73+
})
74+
}
75+
}

0 commit comments

Comments
 (0)