mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add pmg setup edit command (#235)
* feat: Add pmg setup edit command * fix: Code review fixes
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
package setup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/shellwords"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewEditCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "edit",
|
||||||
|
Short: "Open the PMG config file in your default editor",
|
||||||
|
Long: `Open the PMG config file in your default editor.
|
||||||
|
|
||||||
|
The editor is resolved in this order:
|
||||||
|
1. $VISUAL
|
||||||
|
2. $EDITOR
|
||||||
|
3. Platform default (vi on Unix, notepad on Windows)
|
||||||
|
|
||||||
|
If the config file does not exist, a template is created first.`,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runEdit()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runEdit() error {
|
||||||
|
cfg := config.Get()
|
||||||
|
path := cfg.ConfigFilePath()
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err := config.WriteTemplateConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat config file %q: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
editor, err := resolveEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
parts, err := shellwords.Split(editor)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid editor command %q: %w", editor, err)
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return fmt.Errorf("editor command is empty")
|
||||||
|
}
|
||||||
|
parts = append(parts, path)
|
||||||
|
|
||||||
|
c := exec.Command(parts[0], parts[1:]...)
|
||||||
|
c.Stdin = os.Stdin
|
||||||
|
c.Stdout = os.Stdout
|
||||||
|
c.Stderr = os.Stderr
|
||||||
|
if err := c.Run(); err != nil {
|
||||||
|
return fmt.Errorf("editor %q exited with error: %w", editor, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveEditor() (string, error) {
|
||||||
|
if v := strings.TrimSpace(os.Getenv("VISUAL")); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
if v := strings.TrimSpace(os.Getenv("EDITOR")); v != "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return "notepad", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("vi"); err == nil {
|
||||||
|
return "vi", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("no editor found: set $VISUAL or $EDITOR")
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ func NewSetupCommand() *cobra.Command {
|
|||||||
setupCmd.AddCommand(NewInstallCommand())
|
setupCmd.AddCommand(NewInstallCommand())
|
||||||
setupCmd.AddCommand(NewRemoveCommand())
|
setupCmd.AddCommand(NewRemoveCommand())
|
||||||
setupCmd.AddCommand(NewInfoCommand())
|
setupCmd.AddCommand(NewInfoCommand())
|
||||||
|
setupCmd.AddCommand(NewEditCommand())
|
||||||
|
|
||||||
return setupCmd
|
return setupCmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Package shellwords provides POSIX-like splitting of shell command strings
|
||||||
|
// into argv tokens. It is intentionally minimal: it handles the cases that
|
||||||
|
// arise from environment variables like $VISUAL or $EDITOR, where users may
|
||||||
|
// quote paths with spaces or pass flags. It is not a full shell parser — it
|
||||||
|
// does not perform variable expansion, command substitution, or globbing.
|
||||||
|
package shellwords
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Split splits s into tokens using POSIX-like rules:
|
||||||
|
//
|
||||||
|
// - Whitespace (space, tab, newline) separates tokens.
|
||||||
|
// - Single quotes preserve their contents verbatim; no escapes are honored.
|
||||||
|
// - Double quotes preserve whitespace; backslash escapes only ", \, $, `.
|
||||||
|
// - Outside quotes a backslash escapes the next character literally.
|
||||||
|
// - Empty quoted strings ("" or '') produce an empty token.
|
||||||
|
//
|
||||||
|
// Split returns an error for unterminated quoted strings or a trailing
|
||||||
|
// backslash with nothing to escape.
|
||||||
|
func Split(s string) ([]string, error) {
|
||||||
|
var (
|
||||||
|
out []string
|
||||||
|
buf strings.Builder
|
||||||
|
inSingle bool
|
||||||
|
inDouble bool
|
||||||
|
escaped bool
|
||||||
|
hasToken bool
|
||||||
|
)
|
||||||
|
|
||||||
|
flush := func() {
|
||||||
|
if hasToken {
|
||||||
|
out = append(out, buf.String())
|
||||||
|
buf.Reset()
|
||||||
|
hasToken = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
switch {
|
||||||
|
case escaped:
|
||||||
|
buf.WriteByte(c)
|
||||||
|
hasToken = true
|
||||||
|
escaped = false
|
||||||
|
case inSingle:
|
||||||
|
if c == '\'' {
|
||||||
|
inSingle = false
|
||||||
|
} else {
|
||||||
|
buf.WriteByte(c)
|
||||||
|
}
|
||||||
|
hasToken = true
|
||||||
|
case inDouble:
|
||||||
|
switch c {
|
||||||
|
case '"':
|
||||||
|
inDouble = false
|
||||||
|
case '\\':
|
||||||
|
if i+1 >= len(s) {
|
||||||
|
return nil, fmt.Errorf("dangling backslash")
|
||||||
|
}
|
||||||
|
next := s[i+1]
|
||||||
|
// Inside double quotes, backslash only escapes a few
|
||||||
|
// characters; otherwise it is literal.
|
||||||
|
if next == '"' || next == '\\' || next == '$' || next == '`' {
|
||||||
|
buf.WriteByte(next)
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
buf.WriteByte(c)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
buf.WriteByte(c)
|
||||||
|
}
|
||||||
|
hasToken = true
|
||||||
|
default:
|
||||||
|
switch c {
|
||||||
|
case '\'':
|
||||||
|
inSingle = true
|
||||||
|
hasToken = true
|
||||||
|
case '"':
|
||||||
|
inDouble = true
|
||||||
|
hasToken = true
|
||||||
|
case '\\':
|
||||||
|
escaped = true
|
||||||
|
case ' ', '\t', '\n':
|
||||||
|
flush()
|
||||||
|
default:
|
||||||
|
buf.WriteByte(c)
|
||||||
|
hasToken = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inSingle || inDouble {
|
||||||
|
return nil, fmt.Errorf("unterminated quoted string")
|
||||||
|
}
|
||||||
|
if escaped {
|
||||||
|
return nil, fmt.Errorf("dangling backslash")
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package shellwords
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSplit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
want []string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
input: " \t\n ",
|
||||||
|
want: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single token",
|
||||||
|
input: "vi",
|
||||||
|
want: []string{"vi"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "command with flag",
|
||||||
|
input: "code --wait",
|
||||||
|
want: []string{"code", "--wait"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "collapses repeated whitespace",
|
||||||
|
input: "nvim -p \t -u NONE",
|
||||||
|
want: []string{"nvim", "-p", "-u", "NONE"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading and trailing whitespace",
|
||||||
|
input: " vim ",
|
||||||
|
want: []string{"vim"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double-quoted path with spaces",
|
||||||
|
input: `"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" --wait`,
|
||||||
|
want: []string{
|
||||||
|
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
|
||||||
|
"--wait",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single-quoted path with spaces",
|
||||||
|
input: `'/usr/local/bin/my editor' -f`,
|
||||||
|
want: []string{"/usr/local/bin/my editor", "-f"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash-escaped space",
|
||||||
|
input: `/usr/local/bin/my\ editor -f`,
|
||||||
|
want: []string{"/usr/local/bin/my editor", "-f"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double quote inside double quotes via backslash",
|
||||||
|
input: `code -e "say \"hi\""`,
|
||||||
|
want: []string{"code", "-e", `say "hi"`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single quotes inside double quotes are literal",
|
||||||
|
input: `vim "it's fine"`,
|
||||||
|
want: []string{"vim", "it's fine"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double quotes inside single quotes are literal",
|
||||||
|
input: `vim 'a "quote" here'`,
|
||||||
|
want: []string{"vim", `a "quote" here`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "backslash inside single quotes is literal",
|
||||||
|
input: `vim 'a\b'`,
|
||||||
|
want: []string{"vim", `a\b`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-special backslash in double quotes is literal",
|
||||||
|
input: `vim "a\b"`,
|
||||||
|
want: []string{"vim", `a\b`},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "adjacent quoted segments concatenate into one token",
|
||||||
|
input: `vim "foo"'bar'baz`,
|
||||||
|
want: []string{"vim", "foobarbaz"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty double-quoted token",
|
||||||
|
input: `cmd "" arg`,
|
||||||
|
want: []string{"cmd", "", "arg"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty single-quoted token",
|
||||||
|
input: `cmd '' arg`,
|
||||||
|
want: []string{"cmd", "", "arg"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated double quote",
|
||||||
|
input: `vim "abc`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unterminated single quote",
|
||||||
|
input: `vim 'abc`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing backslash outside quotes",
|
||||||
|
input: `vim \`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trailing backslash inside double quotes",
|
||||||
|
input: `vim "abc\`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := Split(tt.input)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user