From a8265e5c065bf2dcd12d315a30216011dc20a5ed Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:07:19 -0700 Subject: [PATCH 1/7] feat: switch execute to orchestrator API, add secret command - Execute now uses POST /api/v1/vm/{id}/exec/stream by default instead of direct SSH. This routes through the vsock agent, which inherits /etc/environment (secrets/env vars). --ssh flag preserves legacy SSH behavior. - Flags pass through correctly to remote commands (SetInterspersed) - New 'vers secret' command (set/list/delete) with: - Masked output by default (--reveal to show) - Hidden terminal input when value omitted - Piped stdin support - New exec service (internal/services/vm/exec.go) for orchestrator API - Cross-reference from 'vers env' help to 'vers secret' --- cmd/env.go | 5 +- cmd/execute.go | 35 +++- cmd/secret.go | 244 +++++++++++++++++++++++++++ cmd/secret_test.go | 54 ++++++ internal/handlers/execute.go | 123 +++++++++++++- internal/presenters/execute_types.go | 1 + internal/services/vm/exec.go | 122 ++++++++++++++ 7 files changed, 573 insertions(+), 11 deletions(-) create mode 100644 cmd/secret.go create mode 100644 cmd/secret_test.go create mode 100644 internal/services/vm/exec.go diff --git a/cmd/env.go b/cmd/env.go index 4422cea..840d23b 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -23,7 +23,10 @@ var envCmd = &cobra.Command{ Environment variables are written to /etc/environment in newly created VMs, where they are available for SSH sessions and exec'd processes. -Use subcommands to list, set, or delete environment variables.`, +Use subcommands to list, set, or delete environment variables. + +For sensitive values (API keys, tokens, passwords), consider using +"vers secret" instead — it masks values in output and supports hidden input.`, } // envListCmd represents the env list command diff --git a/cmd/execute.go b/cmd/execute.go index 981e532..430fa20 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -2,6 +2,7 @@ package cmd import ( "context" + "os" "time" "github.com/hdresearch/vers-cli/internal/handlers" @@ -10,13 +11,21 @@ import ( ) var executeTimeout int +var executeSSH bool +var executeWorkDir string // executeCmd represents the execute command var executeCmd = &cobra.Command{ Use: "execute [vm-id|alias] [args...]", Short: "Run a command on a specific VM", - Long: `Execute a command within the Vers environment on the specified VM. -If no VM is specified, the current HEAD VM is used.`, + Long: `Execute a command on the specified VM via the orchestrator API. + +The command runs through the in-VM agent, which means it automatically +inherits environment variables and secrets configured for your account. + +If no VM is specified, the current HEAD VM is used. + +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 { // Use custom timeout if specified, otherwise use default APIMedium @@ -44,17 +53,35 @@ If no VM is specified, the current HEAD VM is used.`, command = args[1:] } - view, err := handlers.HandleExecute(apiCtx, application, handlers.ExecuteReq{Target: target, Command: command}) + var timeoutSec uint64 + if executeTimeout > 0 { + timeoutSec = uint64(executeTimeout) + } + + view, err := handlers.HandleExecute(apiCtx, application, handlers.ExecuteReq{ + Target: target, + Command: command, + WorkingDir: executeWorkDir, + TimeoutSec: timeoutSec, + UseSSH: executeSSH, + }) if err != nil { return err } pres.RenderExecute(application, view) + + // Exit with the command's exit code + if view.ExitCode != 0 { + os.Exit(view.ExitCode) + } return nil }, } func init() { rootCmd.AddCommand(executeCmd) - executeCmd.Flags().String("host", "", "Specify the host IP to connect to (overrides default)") + executeCmd.Flags().SetInterspersed(false) // stop flag parsing after first positional arg 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 orchestrator API") + executeCmd.Flags().StringVarP(&executeWorkDir, "workdir", "w", "", "Working directory for the command") } diff --git a/cmd/secret.go b/cmd/secret.go new file mode 100644 index 0000000..648a69f --- /dev/null +++ b/cmd/secret.go @@ -0,0 +1,244 @@ +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "sort" + "strings" + "text/tabwriter" + + "github.com/hdresearch/vers-cli/internal/handlers" + pres "github.com/hdresearch/vers-cli/internal/presenters" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +var secretFormat string + +var secretCmd = &cobra.Command{ + Use: "secret", + Short: "Manage secrets", + Long: `Manage secrets that are securely injected into VMs at startup. + +Secrets are written to /etc/environment in newly created VMs, where they +are available to all processes (SSH sessions, exec'd commands, dev servers). + +On cross-account operations (restoring or branching from another user's +public commit), secrets from the original owner are automatically cleared +and replaced with yours. + +Use subcommands to list, set, or delete secrets. + +Examples: + vers secret set ANTHROPIC_API_KEY + vers secret set DATABASE_URL postgres://localhost/mydb + vers secret list + vers secret delete OLD_TOKEN`, + Aliases: []string{"secrets"}, +} + +var secretListCmd = &cobra.Command{ + Use: "list", + Short: "List all secrets", + Long: `List all secrets configured for your account. + +Secret values are masked by default. Use --reveal to show full values.`, + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + reveal, _ := cmd.Flags().GetBool("reveal") + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + vars, err := handlers.HandleEnvList(apiCtx, application, handlers.EnvListReq{}) + if err != nil { + return err + } + + format := pres.ParseFormat(false, secretFormat) + switch format { + case pres.FormatJSON: + if reveal { + pres.PrintJSON(vars) + } else { + masked := make(map[string]string, len(vars)) + for k, v := range vars { + masked[k] = maskValue(v) + } + pres.PrintJSON(masked) + } + default: + if len(vars) == 0 { + fmt.Println("No secrets configured.") + fmt.Println("") + fmt.Println("Set one with: vers secret set MY_API_KEY") + return nil + } + + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "KEY\tVALUE") + for _, key := range keys { + value := vars[key] + if !reveal { + value = maskValue(value) + } + fmt.Fprintf(w, "%s\t%s\n", key, value) + } + w.Flush() + } + return nil + }, +} + +var secretSetCmd = &cobra.Command{ + Use: "set KEY [VALUE]", + Short: "Set a secret", + Long: `Set a secret that will be injected into newly created VMs. + +If VALUE is omitted, you'll be prompted to enter it (hidden input). +If stdin is piped, the value is read from stdin. + +The key must be a valid shell identifier (letters, digits, underscores only, +cannot start with a digit). + +Examples: + vers secret set ANTHROPIC_API_KEY # prompts for value + vers secret set DATABASE_URL postgres://... # inline value + echo "sk-ant-..." | vers secret set API_KEY # from pipe + cat .env.secret | vers secret set API_KEY # from file`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + if key == "" { + return fmt.Errorf("key cannot be empty") + } + if !isValidEnvKey(key) { + return fmt.Errorf("invalid key '%s': must start with letter or underscore, contain only letters, digits, and underscores", key) + } + + var value string + + if len(args) == 2 { + // Value provided as argument + value = args[1] + } else { + // Read value from stdin (piped) or prompt (interactive) + var err error + value, err = readSecretValue(key) + if err != nil { + return fmt.Errorf("failed to read secret value: %w", err) + } + } + + if value == "" { + return fmt.Errorf("secret value cannot be empty") + } + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + err := handlers.HandleEnvSet(apiCtx, application, handlers.EnvSetReq{ + Key: key, + Value: value, + }) + if err != nil { + return err + } + + fmt.Printf("Secret %s set successfully.\n", key) + fmt.Println("This secret will be available in newly created VMs.") + return nil + }, +} + +var secretDeleteCmd = &cobra.Command{ + Use: "delete KEY", + Short: "Delete a secret", + Long: `Delete a secret. + +This removes the secret from your configuration. It will no longer be +injected into newly created VMs (existing VMs are not affected). + +Examples: + vers secret delete OLD_API_KEY + vers secret rm DATABASE_URL`, + Aliases: []string{"del", "rm", "remove"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + if key == "" { + return fmt.Errorf("key cannot be empty") + } + + apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) + defer cancel() + + err := handlers.HandleEnvDelete(apiCtx, application, handlers.EnvDeleteReq{ + Key: key, + }) + if err != nil { + return err + } + + fmt.Printf("Secret %s deleted.\n", key) + return nil + }, +} + +// readSecretValue reads a secret value from stdin. +// If stdin is a terminal, prompts with hidden input. +// If stdin is piped, reads the first line. +func readSecretValue(key string) (string, error) { + if term.IsTerminal(int(os.Stdin.Fd())) { + // Interactive — prompt with hidden input + fmt.Fprintf(os.Stderr, "Enter value for %s: ", key) + raw, err := term.ReadPassword(int(os.Stdin.Fd())) + fmt.Fprintln(os.Stderr) // newline after hidden input + if err != nil { + return "", err + } + return strings.TrimSpace(string(raw)), nil + } + + // Piped — read from stdin + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()), nil + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("no input received on stdin") +} + +// maskValue masks a secret value for display, showing only a prefix hint. +func maskValue(value string) string { + if len(value) <= 4 { + return "****" + } + if len(value) <= 8 { + return value[:2] + "****" + } + return value[:4] + "****" + value[len(value)-2:] +} + +func init() { + rootCmd.AddCommand(secretCmd) + + secretCmd.AddCommand(secretListCmd) + secretCmd.AddCommand(secretSetCmd) + secretCmd.AddCommand(secretDeleteCmd) + + secretListCmd.Flags().StringVar(&secretFormat, "format", "", "Output format (json)") + secretListCmd.Flags().Bool("reveal", false, "Show full secret values (unmasked)") +} diff --git a/cmd/secret_test.go b/cmd/secret_test.go new file mode 100644 index 0000000..f88d07b --- /dev/null +++ b/cmd/secret_test.go @@ -0,0 +1,54 @@ +package cmd + +import "testing" + +func TestMaskValue(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + // Short values — fully masked + {"empty", "", "****"}, + {"1 char", "a", "****"}, + {"4 chars", "abcd", "****"}, + + // Medium values — show prefix + {"5 chars", "abcde", "ab****"}, + {"8 chars", "abcdefgh", "ab****"}, + + // Longer values — show prefix and suffix + {"9 chars", "abcdefghi", "abcd****hi"}, + {"API key", "sk-ant-api03-abc123xyz", "sk-a****yz"}, + {"URL", "postgres://user:pass@host/db", "post****db"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := maskValue(tt.input) + if got != tt.expected { + t.Errorf("maskValue(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestMaskValueNeverRevealsFullSecret(t *testing.T) { + secrets := []string{ + "short", + "medium-length", + "sk-ant-api03-very-long-secret-key-here", + "postgres://admin:hunter2@prod.db.example.com:5432/myapp", + } + + for _, secret := range secrets { + masked := maskValue(secret) + if masked == secret { + t.Errorf("maskValue(%q) returned the unmasked value", secret) + } + if len(masked) > len(secret) { + // Masked output shouldn't be longer than the original + // (it's fine if it is, but worth flagging) + } + } +} diff --git a/internal/handlers/execute.go b/internal/handlers/execute.go index 2c2aaf2..6f94145 100644 --- a/internal/handlers/execute.go +++ b/internal/handlers/execute.go @@ -1,8 +1,12 @@ package handlers import ( + "bufio" "context" + "encoding/base64" + "encoding/json" "fmt" + "io" "github.com/hdresearch/vers-cli/internal/app" "github.com/hdresearch/vers-cli/internal/presenters" @@ -13,8 +17,28 @@ import ( ) type ExecuteReq struct { - Target string - Command []string + Target string + Command []string + WorkingDir string + Env map[string]string + TimeoutSec uint64 + UseSSH bool +} + +// streamResponse represents a single NDJSON line from the exec stream. +// The orchestrator flattens the agent protocol into: +// +// {"type":"chunk","stream":"stdout","data_b64":"...","cursor":N,"exec_id":"..."} +// {"type":"exit","exit_code":0,"cursor":N,"exec_id":"..."} +// {"type":"error","code":"...","message":"..."} +type streamResponse struct { + Type string `json:"type"` + Stream string `json:"stream,omitempty"` + DataB64 string `json:"data_b64,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Cursor uint64 `json:"cursor,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` } func HandleExecute(ctx context.Context, a *app.App, r ExecuteReq) (presenters.ExecuteView, error) { @@ -27,21 +51,108 @@ func HandleExecute(ctx context.Context, a *app.App, r ExecuteReq) (presenters.Ex v.UsedHEAD = t.UsedHEAD v.HeadID = t.HeadID + if r.UseSSH { + return handleExecuteSSH(ctx, a, r, t, v) + } + + return handleExecuteAPI(ctx, a, r, t, v) +} + +// handleExecuteAPI runs the command via the orchestrator exec/stream API. +func handleExecuteAPI(ctx context.Context, a *app.App, r ExecuteReq, t utils.TargetResult, v presenters.ExecuteView) (presenters.ExecuteView, error) { + // Wrap the command in bash -c so shell features work + command := []string{"bash", "-c", utils.ShellJoin(r.Command)} + + body, err := vmSvc.ExecStream(ctx, t.Ident, vmSvc.ExecRequest{ + Command: command, + Env: r.Env, + WorkingDir: r.WorkingDir, + TimeoutSec: r.TimeoutSec, + }) + if err != nil { + return v, fmt.Errorf("exec: %w", err) + } + defer body.Close() + + exitCode, err := streamExecOutput(body, a.IO.Out, a.IO.Err) + if err != nil { + return v, fmt.Errorf("exec stream: %w", err) + } + + v.ExitCode = exitCode + return v, nil +} + +// handleExecuteSSH runs the command via direct SSH (legacy fallback). +func handleExecuteSSH(ctx context.Context, a *app.App, r ExecuteReq, t utils.TargetResult, v presenters.ExecuteView) (presenters.ExecuteView, error) { info, err := vmSvc.GetConnectInfo(ctx, a.Client, t.Ident) if err != nil { return v, fmt.Errorf("failed to get VM information: %w", err) } - sshHost := info.Host cmdStr := utils.ShellJoin(r.Command) - - client := sshutil.NewClient(sshHost, info.KeyPath, info.VMDomain) + client := sshutil.NewClient(info.Host, info.KeyPath, info.VMDomain) err = client.Execute(ctx, cmdStr, a.IO.Out, a.IO.Err) if err != nil { if exitErr, ok := err.(*ssh.ExitError); ok { - return v, fmt.Errorf("command exited with code %d", exitErr.ExitStatus()) + v.ExitCode = exitErr.ExitStatus() + return v, nil } return v, fmt.Errorf("failed to execute command: %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) { + scanner := bufio.NewScanner(body) + // Allow large lines (agent can send up to 10MB of output) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + + exitCode := 0 + + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var resp streamResponse + if err := json.Unmarshal(line, &resp); err != nil { + // Skip unparseable lines + continue + } + + switch resp.Type { + case "chunk": + data, err := base64.StdEncoding.DecodeString(resp.DataB64) + if err != nil { + continue + } + switch resp.Stream { + case "stdout": + stdout.Write(data) + case "stderr": + stderr.Write(data) + } + + case "exit": + if resp.ExitCode != nil { + exitCode = *resp.ExitCode + } + return exitCode, nil + + case "error": + return 1, fmt.Errorf("exec error [%s]: %s", resp.Code, resp.Message) + } + } + + if err := scanner.Err(); err != nil { + return 1, fmt.Errorf("stream read error: %w", err) + } + + return exitCode, nil +} + + diff --git a/internal/presenters/execute_types.go b/internal/presenters/execute_types.go index 335cfca..87e5376 100644 --- a/internal/presenters/execute_types.go +++ b/internal/presenters/execute_types.go @@ -3,4 +3,5 @@ package presenters type ExecuteView struct { UsedHEAD bool HeadID string + ExitCode int } diff --git a/internal/services/vm/exec.go b/internal/services/vm/exec.go new file mode 100644 index 0000000..9400da2 --- /dev/null +++ b/internal/services/vm/exec.go @@ -0,0 +1,122 @@ +package vm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/hdresearch/vers-cli/internal/auth" +) + +// ExecRequest matches the orchestrator's VmExecRequest. +type ExecRequest struct { + Command []string `json:"command"` + Env map[string]string `json:"env,omitempty"` + WorkingDir string `json:"working_dir,omitempty"` + Stdin string `json:"stdin,omitempty"` + TimeoutSec uint64 `json:"timeout_secs,omitempty"` +} + +// ExecResponse matches the orchestrator's VmExecResponse. +type ExecResponse struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` +} + +// ExecStreamChunk is a single line from the NDJSON exec stream. +type ExecStreamChunk struct { + Type string `json:"type"` // "chunk" or "exit" + Stream string `json:"stream,omitempty"` // "stdout" or "stderr" + Data string `json:"data,omitempty"` // base64-encoded bytes + ExitCode *int `json:"exit_code,omitempty"` // only on type=="exit" +} + +// Exec runs a command on a VM via the orchestrator API (non-streaming). +func Exec(ctx context.Context, vmID string, req ExecRequest) (*ExecResponse, error) { + apiKey, err := auth.GetAPIKey() + if err != nil { + return nil, fmt.Errorf("failed to get API key: %w", err) + } + + baseURL, err := auth.GetVersUrl() + if err != nil { + return nil, fmt.Errorf("failed to get API URL: %w", err) + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/vm/%s/exec", baseURL.String(), vmID) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + errBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(errBody)) + } + + var result ExecResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +// ExecStream runs a command on a VM via the orchestrator streaming API. +// It returns the response body for the caller to consume as NDJSON. +func ExecStream(ctx context.Context, vmID string, req ExecRequest) (io.ReadCloser, error) { + apiKey, err := auth.GetAPIKey() + if err != nil { + return nil, fmt.Errorf("failed to get API key: %w", err) + } + + baseURL, err := auth.GetVersUrl() + if err != nil { + return nil, fmt.Errorf("failed to get API URL: %w", err) + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + url := fmt.Sprintf("%s/api/v1/vm/%s/exec/stream", baseURL.String(), vmID) + httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+apiKey) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + if resp.StatusCode != http.StatusOK { + defer resp.Body.Close() + errBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API error %d: %s", resp.StatusCode, string(errBody)) + } + + return resp.Body, nil +} From 503be15e56ff2f291c9064b9608e1e8a1a848710 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:22:13 -0700 Subject: [PATCH 2/7] style: gofmt formatting --- internal/handlers/execute.go | 16 +++++++--------- internal/services/vm/exec.go | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/internal/handlers/execute.go b/internal/handlers/execute.go index 6f94145..2e9a684 100644 --- a/internal/handlers/execute.go +++ b/internal/handlers/execute.go @@ -32,13 +32,13 @@ type ExecuteReq struct { // {"type":"exit","exit_code":0,"cursor":N,"exec_id":"..."} // {"type":"error","code":"...","message":"..."} type streamResponse struct { - Type string `json:"type"` - Stream string `json:"stream,omitempty"` - DataB64 string `json:"data_b64,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - Cursor uint64 `json:"cursor,omitempty"` - Code string `json:"code,omitempty"` - Message string `json:"message,omitempty"` + Type string `json:"type"` + Stream string `json:"stream,omitempty"` + DataB64 string `json:"data_b64,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + Cursor uint64 `json:"cursor,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` } func HandleExecute(ctx context.Context, a *app.App, r ExecuteReq) (presenters.ExecuteView, error) { @@ -154,5 +154,3 @@ func streamExecOutput(body io.Reader, stdout, stderr io.Writer) (int, error) { return exitCode, nil } - - diff --git a/internal/services/vm/exec.go b/internal/services/vm/exec.go index 9400da2..c1122db 100644 --- a/internal/services/vm/exec.go +++ b/internal/services/vm/exec.go @@ -29,7 +29,7 @@ type ExecResponse struct { // ExecStreamChunk is a single line from the NDJSON exec stream. type ExecStreamChunk struct { - Type string `json:"type"` // "chunk" or "exit" + Type string `json:"type"` // "chunk" or "exit" Stream string `json:"stream,omitempty"` // "stdout" or "stderr" Data string `json:"data,omitempty"` // base64-encoded bytes ExitCode *int `json:"exit_code,omitempty"` // only on type=="exit" From 6191bd382f3b62cb7ca3191c816f5b8c6358d115 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:29:50 -0700 Subject: [PATCH 3/7] feat: add exec alias for execute command --- cmd/execute.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/execute.go b/cmd/execute.go index 430fa20..5e9615b 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -16,7 +16,8 @@ var executeWorkDir string // executeCmd represents the execute command var executeCmd = &cobra.Command{ - Use: "execute [vm-id|alias] [args...]", + Use: "execute [vm-id|alias] [args...]", + Aliases: []string{"exec"}, Short: "Run a command on a specific VM", Long: `Execute a command on the specified VM via the orchestrator API. From 403980a5c49affc8f0ae217fea422254921a05f8 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:29:56 -0700 Subject: [PATCH 4/7] style: gofmt --- cmd/execute.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/execute.go b/cmd/execute.go index 5e9615b..ac7ee36 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -18,7 +18,7 @@ var executeWorkDir string var executeCmd = &cobra.Command{ Use: "execute [vm-id|alias] [args...]", Aliases: []string{"exec"}, - Short: "Run a command on a specific VM", + Short: "Run a command on a specific VM", Long: `Execute a command on the specified VM via the orchestrator API. The command runs through the in-VM agent, which means it automatically From 46c984a72d4ac704d33ab4d88c619a9e48c9b2e9 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:30:27 -0700 Subject: [PATCH 5/7] docs: tweak --ssh flag description --- cmd/execute.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/execute.go b/cmd/execute.go index ac7ee36..c021df3 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -83,6 +83,6 @@ func init() { rootCmd.AddCommand(executeCmd) executeCmd.Flags().SetInterspersed(false) // stop flag parsing after first positional arg 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 orchestrator API") + 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") } From cb7b0dd6a401fba85243a0096a8c5225f8aee600 Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Mon, 6 Apr 2026 19:53:03 -0700 Subject: [PATCH 6/7] chore: remove vers secret command (not ready yet) --- cmd/secret.go | 244 --------------------------------------------- cmd/secret_test.go | 54 ---------- 2 files changed, 298 deletions(-) delete mode 100644 cmd/secret.go delete mode 100644 cmd/secret_test.go diff --git a/cmd/secret.go b/cmd/secret.go deleted file mode 100644 index 648a69f..0000000 --- a/cmd/secret.go +++ /dev/null @@ -1,244 +0,0 @@ -package cmd - -import ( - "bufio" - "context" - "fmt" - "os" - "sort" - "strings" - "text/tabwriter" - - "github.com/hdresearch/vers-cli/internal/handlers" - pres "github.com/hdresearch/vers-cli/internal/presenters" - "github.com/spf13/cobra" - "golang.org/x/term" -) - -var secretFormat string - -var secretCmd = &cobra.Command{ - Use: "secret", - Short: "Manage secrets", - Long: `Manage secrets that are securely injected into VMs at startup. - -Secrets are written to /etc/environment in newly created VMs, where they -are available to all processes (SSH sessions, exec'd commands, dev servers). - -On cross-account operations (restoring or branching from another user's -public commit), secrets from the original owner are automatically cleared -and replaced with yours. - -Use subcommands to list, set, or delete secrets. - -Examples: - vers secret set ANTHROPIC_API_KEY - vers secret set DATABASE_URL postgres://localhost/mydb - vers secret list - vers secret delete OLD_TOKEN`, - Aliases: []string{"secrets"}, -} - -var secretListCmd = &cobra.Command{ - Use: "list", - Short: "List all secrets", - Long: `List all secrets configured for your account. - -Secret values are masked by default. Use --reveal to show full values.`, - Aliases: []string{"ls"}, - RunE: func(cmd *cobra.Command, args []string) error { - reveal, _ := cmd.Flags().GetBool("reveal") - - apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) - defer cancel() - - vars, err := handlers.HandleEnvList(apiCtx, application, handlers.EnvListReq{}) - if err != nil { - return err - } - - format := pres.ParseFormat(false, secretFormat) - switch format { - case pres.FormatJSON: - if reveal { - pres.PrintJSON(vars) - } else { - masked := make(map[string]string, len(vars)) - for k, v := range vars { - masked[k] = maskValue(v) - } - pres.PrintJSON(masked) - } - default: - if len(vars) == 0 { - fmt.Println("No secrets configured.") - fmt.Println("") - fmt.Println("Set one with: vers secret set MY_API_KEY") - return nil - } - - keys := make([]string, 0, len(vars)) - for k := range vars { - keys = append(keys, k) - } - sort.Strings(keys) - - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "KEY\tVALUE") - for _, key := range keys { - value := vars[key] - if !reveal { - value = maskValue(value) - } - fmt.Fprintf(w, "%s\t%s\n", key, value) - } - w.Flush() - } - return nil - }, -} - -var secretSetCmd = &cobra.Command{ - Use: "set KEY [VALUE]", - Short: "Set a secret", - Long: `Set a secret that will be injected into newly created VMs. - -If VALUE is omitted, you'll be prompted to enter it (hidden input). -If stdin is piped, the value is read from stdin. - -The key must be a valid shell identifier (letters, digits, underscores only, -cannot start with a digit). - -Examples: - vers secret set ANTHROPIC_API_KEY # prompts for value - vers secret set DATABASE_URL postgres://... # inline value - echo "sk-ant-..." | vers secret set API_KEY # from pipe - cat .env.secret | vers secret set API_KEY # from file`, - Args: cobra.RangeArgs(1, 2), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - - if key == "" { - return fmt.Errorf("key cannot be empty") - } - if !isValidEnvKey(key) { - return fmt.Errorf("invalid key '%s': must start with letter or underscore, contain only letters, digits, and underscores", key) - } - - var value string - - if len(args) == 2 { - // Value provided as argument - value = args[1] - } else { - // Read value from stdin (piped) or prompt (interactive) - var err error - value, err = readSecretValue(key) - if err != nil { - return fmt.Errorf("failed to read secret value: %w", err) - } - } - - if value == "" { - return fmt.Errorf("secret value cannot be empty") - } - - apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) - defer cancel() - - err := handlers.HandleEnvSet(apiCtx, application, handlers.EnvSetReq{ - Key: key, - Value: value, - }) - if err != nil { - return err - } - - fmt.Printf("Secret %s set successfully.\n", key) - fmt.Println("This secret will be available in newly created VMs.") - return nil - }, -} - -var secretDeleteCmd = &cobra.Command{ - Use: "delete KEY", - Short: "Delete a secret", - Long: `Delete a secret. - -This removes the secret from your configuration. It will no longer be -injected into newly created VMs (existing VMs are not affected). - -Examples: - vers secret delete OLD_API_KEY - vers secret rm DATABASE_URL`, - Aliases: []string{"del", "rm", "remove"}, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - - if key == "" { - return fmt.Errorf("key cannot be empty") - } - - apiCtx, cancel := context.WithTimeout(context.Background(), application.Timeouts.APIMedium) - defer cancel() - - err := handlers.HandleEnvDelete(apiCtx, application, handlers.EnvDeleteReq{ - Key: key, - }) - if err != nil { - return err - } - - fmt.Printf("Secret %s deleted.\n", key) - return nil - }, -} - -// readSecretValue reads a secret value from stdin. -// If stdin is a terminal, prompts with hidden input. -// If stdin is piped, reads the first line. -func readSecretValue(key string) (string, error) { - if term.IsTerminal(int(os.Stdin.Fd())) { - // Interactive — prompt with hidden input - fmt.Fprintf(os.Stderr, "Enter value for %s: ", key) - raw, err := term.ReadPassword(int(os.Stdin.Fd())) - fmt.Fprintln(os.Stderr) // newline after hidden input - if err != nil { - return "", err - } - return strings.TrimSpace(string(raw)), nil - } - - // Piped — read from stdin - scanner := bufio.NewScanner(os.Stdin) - if scanner.Scan() { - return strings.TrimSpace(scanner.Text()), nil - } - if err := scanner.Err(); err != nil { - return "", err - } - return "", fmt.Errorf("no input received on stdin") -} - -// maskValue masks a secret value for display, showing only a prefix hint. -func maskValue(value string) string { - if len(value) <= 4 { - return "****" - } - if len(value) <= 8 { - return value[:2] + "****" - } - return value[:4] + "****" + value[len(value)-2:] -} - -func init() { - rootCmd.AddCommand(secretCmd) - - secretCmd.AddCommand(secretListCmd) - secretCmd.AddCommand(secretSetCmd) - secretCmd.AddCommand(secretDeleteCmd) - - secretListCmd.Flags().StringVar(&secretFormat, "format", "", "Output format (json)") - secretListCmd.Flags().Bool("reveal", false, "Show full secret values (unmasked)") -} diff --git a/cmd/secret_test.go b/cmd/secret_test.go deleted file mode 100644 index f88d07b..0000000 --- a/cmd/secret_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package cmd - -import "testing" - -func TestMaskValue(t *testing.T) { - tests := []struct { - name string - input string - expected string - }{ - // Short values — fully masked - {"empty", "", "****"}, - {"1 char", "a", "****"}, - {"4 chars", "abcd", "****"}, - - // Medium values — show prefix - {"5 chars", "abcde", "ab****"}, - {"8 chars", "abcdefgh", "ab****"}, - - // Longer values — show prefix and suffix - {"9 chars", "abcdefghi", "abcd****hi"}, - {"API key", "sk-ant-api03-abc123xyz", "sk-a****yz"}, - {"URL", "postgres://user:pass@host/db", "post****db"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := maskValue(tt.input) - if got != tt.expected { - t.Errorf("maskValue(%q) = %q, want %q", tt.input, got, tt.expected) - } - }) - } -} - -func TestMaskValueNeverRevealsFullSecret(t *testing.T) { - secrets := []string{ - "short", - "medium-length", - "sk-ant-api03-very-long-secret-key-here", - "postgres://admin:hunter2@prod.db.example.com:5432/myapp", - } - - for _, secret := range secrets { - masked := maskValue(secret) - if masked == secret { - t.Errorf("maskValue(%q) returned the unmasked value", secret) - } - if len(masked) > len(secret) { - // Masked output shouldn't be longer than the original - // (it's fine if it is, but worth flagging) - } - } -} From 3718fe8bf38c5f01464ec0d2a89fd8c9d6eb4faa Mon Sep 17 00:00:00 2001 From: Tynan Daly Date: Thu, 9 Apr 2026 15:39:32 -0700 Subject: [PATCH 7/7] chore: remove secrets references, make exec the primary command name --- cmd/env.go | 5 +---- cmd/execute.go | 6 +++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/cmd/env.go b/cmd/env.go index 840d23b..4422cea 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -23,10 +23,7 @@ var envCmd = &cobra.Command{ Environment variables are written to /etc/environment in newly created VMs, where they are available for SSH sessions and exec'd processes. -Use subcommands to list, set, or delete environment variables. - -For sensitive values (API keys, tokens, passwords), consider using -"vers secret" instead — it masks values in output and supports hidden input.`, +Use subcommands to list, set, or delete environment variables.`, } // envListCmd represents the env list command diff --git a/cmd/execute.go b/cmd/execute.go index c021df3..22f63c3 100644 --- a/cmd/execute.go +++ b/cmd/execute.go @@ -16,13 +16,13 @@ var executeWorkDir string // executeCmd represents the execute command var executeCmd = &cobra.Command{ - Use: "execute [vm-id|alias] [args...]", - Aliases: []string{"exec"}, + Use: "exec [vm-id|alias] [args...]", + Aliases: []string{"execute"}, Short: "Run a command on a specific VM", Long: `Execute a command on the specified VM via the orchestrator API. The command runs through the in-VM agent, which means it automatically -inherits environment variables and secrets configured for your account. +inherits environment variables configured for your account. If no VM is specified, the current HEAD VM is used.