diff --git a/cli/consumer_command.go b/cli/consumer_command.go index e1dcd859..d36004e1 100644 --- a/cli/consumer_command.go +++ b/cli/consumer_command.go @@ -22,7 +22,6 @@ import ( "math" "math/rand" "os" - "os/exec" "os/signal" "regexp" "sort" @@ -561,11 +560,6 @@ func (c *consumerCmd) leaderStandDownAction(_ *fisk.ParseContext) error { } func (c *consumerCmd) interactiveEdit(cfg api.ConsumerConfig) (*api.ConsumerConfig, error) { - editor := os.Getenv("EDITOR") - if editor == "" { - return &api.ConsumerConfig{}, fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - cj, err := decoratedYamlMarshal(cfg) if err != nil { return &api.ConsumerConfig{}, fmt.Errorf("could not create temporary file: %s", err) @@ -584,14 +578,9 @@ func (c *consumerCmd) interactiveEdit(cfg api.ConsumerConfig) (*api.ConsumerConf tfile.Close() - cmd := exec.Command(editor, tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(tfile.Name()) if err != nil { - return &api.ConsumerConfig{}, fmt.Errorf("could not create temporary file: %s", err) + return &api.ConsumerConfig{}, err } nb, err := os.ReadFile(tfile.Name()) diff --git a/cli/context_command.go b/cli/context_command.go index af48a417..355d810c 100644 --- a/cli/context_command.go +++ b/cli/context_command.go @@ -18,7 +18,6 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "os/user" "sort" "strings" @@ -234,11 +233,6 @@ socks_proxy: {{ .SocksProxy | t }} ` func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { - editor := os.Getenv("EDITOR") - if editor == "" { - return fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - if !natscontext.IsKnown(c.name) { return fmt.Errorf("unknown context %q", c.name) } @@ -247,7 +241,7 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { if err != nil { return err } - editFile := path + editFp := path var ctx *natscontext.Context @@ -286,21 +280,16 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { } f.Close() - editFile = f.Name() + editFp = f.Name() } - cmd := exec.Command(editor, editFile) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(editFp) if err != nil { return err } - if path != editFile { - yctx, err := os.ReadFile(editFile) + if path != editFp { + yctx, err := os.ReadFile(editFp) if err != nil { return fmt.Errorf("could not read temporary copy: %w", err) } @@ -332,7 +321,7 @@ func (c *ctxCommand) editCommand(pc *fisk.ParseContext) error { err = c.showCommand(pc) if err != nil { // but not if the file was already corrupt and we are editing the json directly - if path == editFile { + if path == editFp { return err } diff --git a/cli/errors_command.go b/cli/errors_command.go index a8c05d44..dbc4314f 100644 --- a/cli/errors_command.go +++ b/cli/errors_command.go @@ -18,7 +18,6 @@ import ( "errors" "fmt" "os" - "os/exec" "regexp" "sort" "strconv" @@ -28,6 +27,7 @@ import ( "github.com/fatih/color" "github.com/nats-io/jsm.go/schemas" "github.com/nats-io/nats-server/v2/server" + iu "github.com/nats-io/natscli/internal/util" ) type errCmd struct { @@ -122,10 +122,6 @@ func (c *errCmd) listAction(_ *fisk.ParseContext) error { } func (c *errCmd) editAction(pc *fisk.ParseContext) error { - if os.Getenv("EDITOR") == "" { - return fmt.Errorf("EDITOR variable is not set") - } - errs, err := c.loadErrors(nil) if err != nil { return err @@ -162,15 +158,12 @@ func (c *errCmd) editAction(pc *fisk.ParseContext) error { tfile.Write(fj) tfile.Close() - for { - cmd := exec.Command(os.Getenv("EDITOR"), tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + fp := tfile.Name() - err = cmd.Run() + for { + err = iu.EditFile(fp) if err != nil { - return fmt.Errorf("could not edit error: %s", err) + return err } eb, err := os.ReadFile(tfile.Name()) diff --git a/cli/stream_command.go b/cli/stream_command.go index fa6ca129..b25becdd 100644 --- a/cli/stream_command.go +++ b/cli/stream_command.go @@ -21,7 +21,6 @@ import ( "io" "math" "os" - "os/exec" "os/signal" "path/filepath" "sort" @@ -1793,11 +1792,6 @@ func (c *streamCmd) copyAndEditStream(cfg api.StreamConfig, pc *fisk.ParseContex } func (c *streamCmd) interactiveEdit(cfg api.StreamConfig) (api.StreamConfig, error) { - editor := os.Getenv("EDITOR") - if editor == "" { - return api.StreamConfig{}, fmt.Errorf("set EDITOR environment variable to your chosen editor") - } - cj, err := decoratedYamlMarshal(cfg) if err != nil { return api.StreamConfig{}, fmt.Errorf("could not create temporary file: %s", err) @@ -1816,14 +1810,9 @@ func (c *streamCmd) interactiveEdit(cfg api.StreamConfig) (api.StreamConfig, err tfile.Close() - cmd := exec.Command(editor, tfile.Name()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - err = cmd.Run() + err = iu.EditFile(tfile.Name()) if err != nil { - return api.StreamConfig{}, fmt.Errorf("could not create temporary file: %s", err) + return api.StreamConfig{}, err } nb, err := os.ReadFile(tfile.Name()) diff --git a/internal/util/util.go b/internal/util/util.go index b6d25732..2de121ce 100644 --- a/internal/util/util.go +++ b/internal/util/util.go @@ -21,6 +21,7 @@ import ( "io" "math" "os" + "os/exec" "reflect" "regexp" "sort" @@ -29,6 +30,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/dustin/go-humanize" + "github.com/google/shlex" "github.com/guptarohit/asciigraph" "github.com/nats-io/jsm.go" "github.com/nats-io/nats.go" @@ -430,3 +432,41 @@ func ProgressWidth() int { func JSONString(s string) string { return "\"" + s + "\"" } + +// Split the string into a command and its arguments. +func SplitCommand(s string) (string, []string, error) { + cmdAndArgs, err := shlex.Split(s) + if err != nil { + return "", nil, err + } + + cmd := cmdAndArgs[0] + args := cmdAndArgs[1:] + return cmd, args, nil +} + +// Edit the file at filepath f using the environment variable EDITOR command. +func EditFile(f string) error { + rawEditor := os.Getenv("EDITOR") + if rawEditor == "" { + return fmt.Errorf("set EDITOR environment variable to your chosen editor") + } + + editor, args, err := SplitCommand(rawEditor) + if err != nil { + return fmt.Errorf("could not parse EDITOR: %v", rawEditor) + } + + args = append(args, f) + cmd := exec.Command(editor, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + err = cmd.Run() + if err != nil { + return fmt.Errorf("could not edit file %v: %s", f, err) + } + + return nil +} diff --git a/internal/util/util_test.go b/internal/util/util_test.go index 24c02db1..402d2169 100644 --- a/internal/util/util_test.go +++ b/internal/util/util_test.go @@ -14,6 +14,10 @@ package util import ( + "io" + "os" + "slices" + "strings" "testing" ) @@ -34,3 +38,83 @@ func TestMultipleSort(t *testing.T) { t.Fatalf("expected true") } } + +func TestSplitCommand(t *testing.T) { + cmd, args, err := SplitCommand("vim") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "vim" && len(args) != 0 { + t.Fatalf("Expected vim and [], got %v and %v", cmd, args) + } + + cmd, args, err = SplitCommand("code --wait") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "code" && !slices.Equal(args, []string{"--wait"}) { + t.Fatalf("Expected code and [\"--wait\"], got %v and %v", cmd, args) + } + + cmd, args, err = SplitCommand("code --wait --new-window") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + if cmd != "code" && !slices.Equal(args, []string{"--wait", "--new-window"}) { + t.Fatalf("Expected code and [\"--wait\", \"--new-window\"], got %v and %v", cmd, args) + } + + // EOF found when expecting closing quote + _, _, err = SplitCommand("foo --bar 'hello") + if err == nil { + t.Fatal("Expected err to not be nil, got nil") + } +} + +func TestEditFile(t *testing.T) { + r, w, _ := os.Pipe() + os.Stdout = w + defer r.Close() + defer w.Close() + + f, err := os.CreateTemp("", "test_edit_file") + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + defer f.Close() + defer os.Remove(f.Name()) + + t.Run("EDITOR unset", func(t *testing.T) { + os.Unsetenv("EDITOR") + err := EditFile("") + if err == nil { + t.Fatal("Expected err to not be nil, got nil") + } + }) + + t.Run("EDITOR set", func(t *testing.T) { + os.Setenv("EDITOR", "echo") + err := EditFile(f.Name()) + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + + w.Close() + stdout, err := io.ReadAll(r) + if err != nil { + t.Fatalf("Expected err to be nil, got %v", err) + } + r.Close() + + actual := string(stdout) + lines := strings.Split(actual, "\n") + + if len(lines) != 2 || lines[1] != "" { + t.Fatalf("Expected one line of output, got %v", actual) + } + + if !strings.Contains(lines[0], "test_edit_file") { + t.Fatalf("Expected echo output, got %v", actual) + } + }) +}