diff --git a/README.md b/README.md index 2cdbb024b..efe1e24a4 100644 --- a/README.md +++ b/README.md @@ -188,11 +188,10 @@ Select **y** to proceed with your default tenant, or **N** to choose a different ## Customization -To change the text editor used for editing templates, rules, and actions, set the environment variable `EDITOR` to your -preferred editor. If choosing a non-terminal editor, ensure that the command starts the editor and waits for the files -to be closed before returning. +The default text editor is `vim` on Linux/macOS and `notepad` on Windows. To change that for editing templates, rules, and actions, set the environment variable `EDITOR` to your +preferred editor. If choosing a non-terminal editor, ensure that the command starts the editor and waits for the files to be closed before returning. -Examples: +### Linux / macOS ```shell # Uses vscode with the --wait flag. @@ -208,6 +207,26 @@ export EDITOR="nano" export EDITOR="vim" ``` +### Windows + +```powershell +# PowerShell (current session). +$env:EDITOR = "code --wait" +$env:EDITOR = '"C:\Path To\bin\code" --wait' +$env:EDITOR = '"C:\Path To\notepad++.exe" --wait' + +# PowerShell (persistent, across sessions). +[System.Environment]::SetEnvironmentVariable("EDITOR", "code --wait", "User") +``` + +```cmd +REM Command Prompt (current session). +set EDITOR=code --wait + +REM Command Prompt (persistent, across sessions). +setx EDITOR "code --wait" +``` + ## Anonymized Analytics Disclosure Anonymized data points are collected during the use of this CLI. This data includes the CLI version, operating system, timestamp, and other technical details that do not personally identify you. diff --git a/internal/prompt/editor.go b/internal/prompt/editor.go index 153601ad7..be9f6c4ee 100644 --- a/internal/prompt/editor.go +++ b/internal/prompt/editor.go @@ -7,8 +7,6 @@ import ( "os/exec" "runtime" - "github.com/kballard/go-shellquote" - "github.com/auth0/auth0-cli/internal/iostream" ) @@ -40,7 +38,7 @@ type editorPrompt struct { // openFile opens filename in the preferred text editor, resolving the // arguments with editor specific logic. func (p *editorPrompt) openFile(filename string, infoFn func()) error { - args, err := shellquote.Split(p.cmd) + args, err := parseEditorArgs(p.cmd) if err != nil { return err } diff --git a/internal/prompt/editor_unix.go b/internal/prompt/editor_unix.go new file mode 100644 index 000000000..5c52308ad --- /dev/null +++ b/internal/prompt/editor_unix.go @@ -0,0 +1,14 @@ +//go:build !windows +// +build !windows + +package prompt + +import ( + "github.com/kballard/go-shellquote" +) + +// parseEditorArgs parses POSIX shell-style command line arguments +// into a slice of strings suitable for exec.Command. +func parseEditorArgs(cmd string) ([]string, error) { + return shellquote.Split(cmd) +} diff --git a/internal/prompt/editor_unix_test.go b/internal/prompt/editor_unix_test.go new file mode 100644 index 000000000..84d380ffc --- /dev/null +++ b/internal/prompt/editor_unix_test.go @@ -0,0 +1,73 @@ +//go:build !windows +// +build !windows + +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseEditorArgs(t *testing.T) { + tests := []struct { + name string + cmd string + expected []string + wantErr bool + }{ + { + name: "simple editor", + cmd: "vim", + expected: []string{"vim"}, + }, + { + name: "editor with flag", + cmd: "code --wait", + expected: []string{"code", "--wait"}, + }, + { + name: "editor with multiple flags", + cmd: "subl --wait --new-window", + expected: []string{"subl", "--wait", "--new-window"}, + }, + { + name: "path with spaces in double quotes", + cmd: `"/usr/local/bin/my editor" --wait`, + expected: []string{"/usr/local/bin/my editor", "--wait"}, + }, + { + name: "path with spaces in single quotes", + cmd: `'/usr/local/bin/my editor' --wait`, + expected: []string{"/usr/local/bin/my editor", "--wait"}, + }, + { + name: "path without spoaces or quotes", + cmd: `/usr/local/bin/myeditor --wait`, + expected: []string{"/usr/local/bin/myeditor", "--wait"}, + }, + { + name: "empty command", + cmd: ``, + expected: []string{}, + }, + { + name: "unterminated quote", + cmd: `"unterminated`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, err := parseEditorArgs(tt.cmd) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, args) + }) + } +} diff --git a/internal/prompt/editor_windows.go b/internal/prompt/editor_windows.go new file mode 100644 index 000000000..9852aa4fd --- /dev/null +++ b/internal/prompt/editor_windows.go @@ -0,0 +1,44 @@ +//go:build windows +// +build windows + +package prompt + +import ( + "fmt" + "syscall" + "unsafe" +) + +// parseEditorArgs parses Windows-style command line arguments +// into a slice of strings suitable for exec.Command. +// Uses the Windows CommandLineToArgvW API to correctly handle +// backslash path separators and quoted paths with spaces. +func parseEditorArgs(cmd string) ([]string, error) { + if cmd == "" { + return []string{}, nil + } + + // Convert the Go string (UTF-8) to a UTF-16 pointer, as required by Windows APIs. + utf16Cmd, err := syscall.UTF16PtrFromString(cmd) + if err != nil { + return nil, fmt.Errorf("failed to encode editor command: %w", err) + } + + // Use the Windows CommandLineToArgvW API to split the command string into arguments. + // This correctly handles Windows path separators (\) and quoted paths with spaces. + var argc int32 + argv, err := syscall.CommandLineToArgv(utf16Cmd, &argc) + if err != nil { + return nil, fmt.Errorf("failed to parse editor command %q: %w", cmd, err) + } + // argv is allocated by the Windows API and must be freed with LocalFree. + defer syscall.LocalFree(syscall.Handle(unsafe.Pointer(argv))) + + // Convert each UTF-16 encoded argument back to a Go string. + args := make([]string, argc) + for i := range args { + args[i] = syscall.UTF16ToString((*argv[i])[:]) + } + + return args, nil +} diff --git a/internal/prompt/editor_windows_test.go b/internal/prompt/editor_windows_test.go new file mode 100644 index 000000000..bcf6a9ef4 --- /dev/null +++ b/internal/prompt/editor_windows_test.go @@ -0,0 +1,68 @@ +//go:build windows +// +build windows + +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseEditorArgs(t *testing.T) { + tests := []struct { + name string + cmd string + expected []string + wantErr bool + }{ + { + name: "simple editor", + cmd: "notepad", + expected: []string{"notepad"}, + }, + { + name: "editor with flag", + cmd: "code --wait", + expected: []string{"code", "--wait"}, + }, + { + name: "windows path with backslashes", + cmd: `C:\Windows\notepad.exe`, + expected: []string{`C:\Windows\notepad.exe`}, + }, + { + name: "quoted path with spaces", + cmd: `"C:\Program Files\Notepad++\notepad++.exe" --wait`, + expected: []string{`C:\Program Files\Notepad++\notepad++.exe`, "--wait"}, + }, + { + name: "quoted path with spaces and multiple flags", + cmd: `"C:\Program Files\Microsoft VS Code\code.exe" --wait --new-window`, + expected: []string{`C:\Program Files\Microsoft VS Code\code.exe`, "--wait", "--new-window"}, + }, + { + name: "path without spaces or quotes", + cmd: `C:\tools\vim.exe -u C:\Users\me\.vimrc`, + expected: []string{`C:\tools\vim.exe`, "-u", `C:\Users\me\.vimrc`}, + }, + { + name: "empty command", + cmd: ``, + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args, err := parseEditorArgs(tt.cmd) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.expected, args) + }) + } +}