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(NewRemoveCommand())
|
||||
setupCmd.AddCommand(NewInfoCommand())
|
||||
setupCmd.AddCommand(NewEditCommand())
|
||||
|
||||
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